Embedding ThoughtSpot, explained.
How each piece works, which choices matter, and what breaks in production. Every claim links to the docs.
Start here: the big picture
Five questions every embed answers, two choices that shape the rest, and a path for what you are building.
Answer the pillars in order. Each one builds on the answer before it.
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
- Authentication: the docs name cookieless trusted auth as the recommended trusted type; Embedded SSO is the documented alternative if one IdP allows iframes.
- Users, groups & tenants: per-person users are our usual guidance; Orgs, or one Org with tenant rules, trade isolation against operating cost.
- Data security: RLS and token variables. Never runtime filters as security.
- Hardening: allowlists first; blocking full-app access and trimming actions are options.
- Look & feel, then Performance and Environments & releases.
- Finish with the go-live checklist.
Your path: internal portal
- Authentication: Embedded SSO fits one iframe-friendly SAML/OIDC IdP; trusted auth fits when your app owns the session or you have several IdPs.
- How embedding works: pick the component that fits the page.
- Filters and drill-through: connect the embed to the rest of your app.
- Custom actions: hand off from insight to workflow.
- Go-live checklist.
Your path: AI & Spotter
- Spotter & AI: choose the embed or API surface and prepare your Models.
- Data security: RLS and column security also apply to AI answers.
- Authentication: AI surfaces still need a real per-user identity.
- What's new: much of this area is Beta or Early access.
Your path: regulated / high-security
- Users & tenants: per-user identity so the audit log names a person.
- Data security: know which layers are a boundary and which are only UI.
- Hardening: work through the checklist end to end.
- Authentication: the secret key never reaches the browser; token lifetime is a trade-off to decide with your security team.
- Exports: control who can take data out, and how.
Your path: mobile & webview
- Authentication: the mobile SDKs support only cookieless trusted auth (docs). For webviews, test cookie behaviour yourself.
- Performance: prerendering and full-height layouts.
- 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
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.
How embedding works
Who talks to whom on every embedded page: your page, the SDK, an iframe, the cluster and your backend.
- init once
- SDK asks for a tokenThe SDK calls your
getAuthToken(), which calls your backend. See Authentication. - Backend gets the tokenYour server authenticates the user, then asks the ThoughtSpot REST API for a token for them.
- render creates the iframe
new LiveboardEmbed(el, cfg).render()inserts an iframe. docs - Iframe loads from the clusterThe frame loads the app from your cluster and presents the token. Queries run in the cluster under that user’s permissions.
- Events flow both waysYou subscribe to
EmbedEvents with.on()and send commands withembed.trigger(HostEvent...). See SDK lifecycle. - REST goes through your backendPrivileged 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.
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
| Component | Shows | Key input | Choose it when |
|---|---|---|---|
LiveboardEmbed | A Liveboard, or one visualization | liveboardId (+ vizId) | Curated dashboards, KPI tiles |
SearchEmbed | Search bar, data panel, results; or a saved Answer | dataSource or answerId (+ hideSearchBar) | Self-service exploration; standalone saved Answer |
SearchBarEmbed | Search bar only | dataSource | Search in your own UI, results shown by you |
SpotterEmbed | Spotter chat experience | worksheetId (data source) | Natural-language analytics with ThoughtSpot’s UI |
SpotterAgentEmbed | Nothing visible (prompt panel only) | prompts in, structured answers out | Chatbots and custom AI front ends |
AppEmbed | Full ThoughtSpot app or pages | pageId, navigation flags | Whole-product surface; fixed layout acceptable |
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
- 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
AppEmbedwithSearchEmbedorLiveboardEmbedon 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.
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
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.
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.
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
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 lets ThoughtSpot reuse your IdP. Way B keeps ThoughtSpot out of your login.
Way A has three conditions (the docs advise against it otherwise)
None. docsWhich way fits you?
Prefer to click through it? Answer the four questions about 1 min
Go deeper: ten dimensions, side by side about 2 min
| Dimension | EmbeddedSSO | Trusted auth (cookieless) |
|---|---|---|
| Where authentication happens | Inside the ThoughtSpot iframe | In your app, before ThoughtSpot loads |
| Number of IdPs | One | Any, because your backend is the converging point |
| Backend needed | No | Yes, a token service |
| Per-session data rules | Not directly; use group claims, pre-persisted values or an ACL table | First-class: pass values on each mint |
| Offboarding latency | Bound 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 trail | Limited | Every session starts with a mint you can log |
| Third-party cookies | The 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 code | A few lines | A few lines plus a backend endpoint |
| Moving parts | Fewer. 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 future | The 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 Orgs | Any 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
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.
| Question | What the docs say | Options and trade-offs |
|---|---|---|
| MFA | AuthType.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 outage | Not 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 B | The docs choose by IdP conditions, not by regulation. docs | Embedded 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 IdP | Docs recommend trusted auth for multiple IdPs without federation, and advise against Embedded SSO. Per Org Subdomain lets Orgs with different IdPs be identified. docs | Trusted 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 exit | See 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. |
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.
- User opens your appPhase A. A protected route, no session yet. Nothing here involves ThoughtSpot.
- User signs in at your IdPYour 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
- Your app sets its own sessionThis 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.
- The SDK asks for a tokenPhase B begins.
init()calls yourgetAuthTokencallback, which calls your endpoint with the app session attached. docs - Your backend decides whoIt 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
- Backend calls ThoughtSpot with the secret key
- ThoughtSpot returns a tokenThe default validity is 300 seconds. docs
- Backend returns a fresh tokenReturn a new token on every call. A duplicate triggers
DUPLICATE_TOKEN_ERR. docs - The SDK uses it as a bearer tokenIn cookieless mode the token is used on every request and no browser session is created. With
autoLogin: truethe SDK fetches a new token before expiry. docs
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
username, secret_key, org_identifier, validity_time_in_sec (default 300)auto_create, display_name, email, groupsvariable_values + persist_option, optionally scoped with objectsJust-in-time users: the conditions
- Send
auto_create: truewithdisplay_name,emailandgroups([{ "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 RBACCONTROL_TRUSTED_AUTHplus edit access to the data source. docs
- Expect JIT to update an existing user: with
auto_create: truetheir 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_createistrueonauth/token/custom(10.5.0.cl+) butfalseonauth/token/full. docs
ABAC with RLS: how the values reach a query
template/variables/createvariable_values per user, at mint timeThe 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.
secret_key from a secrets manager.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
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.
| Concern | What the docs say | Options and trade-offs |
|---|---|---|
| Availability | The 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 limits | From 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. docs | Lengthening 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. |
| Caching | Return a fresh token on every call; a duplicate triggers DUPLICATE_TOKEN_ERR. docs | Do 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 rotation | Disable and re-enable trusted authentication; the old key stops working at once. Per-Org keys exist from 9.2.0.cl through Support. docs | Per-Org keys limit what one leaked key exposes but multiply keys to manage (our inference). Rotation always needs a coordinated cutover. |
| Audit | Not 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. |
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
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+
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
Cookie-based trusted auth
The token is used once to create a browser session. Works with v1 or v2 tokens.
Redirect to an IdP that cannot be embedded
The SDK redirects the host page, or opens a popup with inPopup: true.
Dev only
Pass-through login
The user logs in to ThoughtSpot in the iframe. The docs say it is not recommended for production. docs
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
EmbeddedSSObehaves likeNoneif 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.SAMLRedirectandOIDCRedirectneed SAML redirect domains allowlisted in Security Settings.Noneneeds partitioned cookies enabled in dev and test.- To avoid reloading the host in redirect flows, use popup mode:
inPopup: true(SDK 1.18.0) withauthTriggerContainerandauthTriggerText, oruseEventForSAMLPopupandauthEE.emit(AuthEvent.TRIGGER_SSO_POPUP). ThenoRedirectoption is deprecated; useinPopup. Deprecated docs
Cookieless or cookie-based
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
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.
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 barefetch()then fails is our inference from those two statements. tokenizedFetchtakes the same arguments as the Webfetchand 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
getAuthTokenfor other REST calls. docs A token never extends, andautoLoginreplaces it before expiry, so a saved copy goes stale (our inference).tokenizedFetchavoids keeping one. - Returning a reused token from
getAuthTokentriggersDUPLICATE_TOKEN_ERR, so do not pointgetAuthTokenat 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 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 viacluster_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
detectCookieAccessSlowunless 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
autoLogin: true requests a new token before expiry.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
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: truemakes the SDK request a new token before the current one or the session expires, so the user never sees a signed-out embed. docsrefreshAuthTokenOnNearExpiryis a view config option (documented onAppViewConfig,SearchViewConfigand 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_identifieror atoken; revoking another user's token needs sufficient privileges per the REST reference. docs
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)
| Endpoint | Fits when | Notes |
|---|---|---|
POST /api/rest/2.0/auth/token/full | You 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/object | Lock 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 GA | You 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.
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
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 happened | Signal | What to do |
|---|---|---|
| Session or token expired | EmbedEvent.AuthExpire | Set autoLogin: true so the SDK refreshes before expiry. |
| Idle timeout | EmbedEvent.IdleSessionTimeout, AuthStatus.FAILURE with IDLE_SESSION_TIMEOUT | Route to your own sign-in or re-init. |
| Auth failed in the iframe | EmbedEvent.AuthFailure | Check the token service response; show your own message. |
| User logged out | EmbedEvent.AuthLogout, AuthStatus.LOGOUT | Clear app state. Call logout() and re-init with autoLogin: true to sign back in. |
| Cookies blocked | EmbedEvent.NoCookieAccess, FAILURE with NO_COOKIE_ACCESS | Switch to cookieless. |
| Duplicate or invalid token | DUPLICATE_TOKEN_ERR, INVALID_TOKEN_ERROR | Always 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
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.
Everyone is one user
- One set of data rules for all
- Every action logs as the same user
- Favorites and saved content are shared
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
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):
| Mapping | Fits when | Gives up |
|---|---|---|
| One user per person | Different 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 role | Everyone in a tenant should see the same data, and shared content is acceptable | Per-person audit, favorites, private content, and per-person persisted RLS values |
| One public user, tight ACL | Identical 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?
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
| Behavior | token/full and token/object | token/custom |
|---|---|---|
auto_create default | false | true (10.5.0.cl+) |
Existing user, auto_create: true | Updates display name, email and groups on every mint | Does not update the existing user |
| Org parameter | org_id (integer) | org_identifier (ID or name) |
group_identifiers | List of names/IDs | List of objects {identifier} |
Sources: the JIT page and the REST v2 authentication guide. docs docs
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
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+): requiresname,display_name,email. Optionalaccount_type(LOCAL_USER,LDAP_USER,SAML_USER,OIDC_USER,REMOTE_USER),org_identifiers,group_identifiers,account_status,visibility,preferred_locale. docsPOST /api/rest/2.0/users/import(9.0.0.cl+): bulk upsert and sync.dry_rundefaults to true, so nothing is written until you set it false.delete_unspecified_usersdefaults 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) ororg_identifier(custom). If omitted with asecret_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, orORG_ADMINISTRATIONunder 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 noteoverrideOrgIdmay not work with trusted auth iftokenAuthPerOrgis enabled. GA docs
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
| Option | Fits when | Costs | Gives up |
|---|---|---|---|
| Org per tenant | Tenants need their own users, groups, models and admins | Orgs 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 ABAC | Many small tenants, shared models acceptable | Rule design and two-tenant testing | Structural isolation; a rule bug is a data leak |
| Separate instances | Contract 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.
| Action | What the docs say | Trade-off |
|---|---|---|
| Stop minting tokens (trusted auth) | An issued v2 token stays valid until it expires; activity never extends it. docs | Free, immediate for new sessions; the existing token lives out its lifetime. |
| Revoke the token | POST /api/rest/2.0/auth/token/revoke (9.0.0.cl+) accepts user_identifier or token. docs | Ends access sooner; needs privileges and an admin-side call from your offboarding flow. |
| Deactivate the user | POST /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. docs | Keeps ownership and audit references; the account still exists. |
| Delete the user | Possible, but the docs prefer deactivation. docs | Cleaner directory, but references to the user are lost. |
Scheduled users/import sync | delete_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. |
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, tokenauto_createneedsCONTROL_TRUSTED_AUTH, managing formula variables for custom tokens needsCAN_MANAGE_VARIABLES, andorgs/createneedsORG_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.
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
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
- The SDK asks your backend for a tokenWith
AuthType.TrustedAuthTokenCookielessthe SDK calls yourgetAuthToken. Return a fresh token each call. See the authentication section. - Your backend decides what this user may seeTake 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.
- Mint with
variable_valuesPOST /api/rest/2.0/auth/token/customwithusername,persist_option(APPENDorREPLACEwith variables),secret_keyandvariable_values: [{name, values[]}]. Optionally scope withobjects: [{type: "LOGICAL_TABLE", identifier}]. docs - ThoughtSpot stores the values and returns a JWTSide 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
- The embed runs with the bearer tokenEvery request carries the token. The user can open content shared with them; the data they get is decided in the next step.
- The RLS rule becomes a WHERE clauseA rule like
region = ts_var(region_var)expands per user. One value becomes=, several becomeIN (...). The user cannot alter it. - The warehouse returns only permitted rowsEverything 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 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.
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
| Option | Enforced where | Freshness | Operational effort | Performance | Audit | What you give up |
|---|---|---|---|---|---|---|
| Group-based RLS | ThoughtSpot, 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 values | Docs give no figure. Test on your warehouse | Membership via users and groups APIs and audit events (docs) | Multi-valued or per-login attributes fit poorly |
| ABAC variables | ThoughtSpot, 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 Authentication | Docs give no figure. Test on your warehouse | variables/search with METADATA_AND_VALUES shows stored values (docs) | Not session-scoped. Needs a ThoughtSpot release with formula variables |
| ACL-table join | ThoughtSpot, 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 figure | The 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 OAuth | The 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 figure | Warehouse-side query history can attribute queries to a person. The docs do not describe this, so confirm on your platform | Seamless 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.EmbeddedSSOflows. - The cost is a pipeline to maintain and a join on every query.
| If this is true | It leans toward | Main alternative, and what would change the answer |
|---|---|---|
| One tenant key per customer, entitlements rarely change | Group-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 service | ABAC variables | ACL table, if the entitlements already live in the warehouse and you prefer one source of truth there |
| Entitlements already live in a warehouse table | ACL-table join | ABAC variables, if you would rather push the values from your app than maintain a pipeline |
| You use EmbeddedSSO and need attributes beyond group claims | Group claims or ACL table | Variables can be pre-persisted with an admin token mint, but expect sync lag |
| Per-session entitlements | Dedicated 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 rules | Warehouse OAuth | ThoughtSpot-side RLS, if sign-in friction or account provisioning is the blocker |
Go deeper: enterprise controls, audit and isolation testing about 4 min
| Concern | What the docs offer | Trade-off or open question |
|---|---|---|
| Column-level security | The owner shares only specific columns of a Table with a user or group. Docs: create separate CLS groups, as with RLS groups. docs docs | Sharing 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 rules | The "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. docs | Our 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 entitlements | Security 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. docs | The 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 RLS | The 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 docs | Orgs 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 residency | Not 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
| Topic | Behavior | Source |
|---|---|---|
persist_option | With 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 default | Every 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 |
| Wildcards | TS_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 scoping | If 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 ABAC | Values 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 |
| Indexing | Token values are not applied to indexing. Disable indexing on sensitive columns, or put RLS on them. | docs |
Groups on /token/custom | Groups are assigned only when the user is created. /token/full and /token/object replace groups on every request. | docs |
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
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
- Audit. List every place your backend sends
filter_rulesorparameter_values, and which columns and Models they touch. Follow the official migration guide. docs - Create variables for each attribute (
variables/create, then reference them in rules). - 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 - Send both for a coexistence window: legacy fields plus
variable_values. Verify with a non-admin user andPOST /api/rest/2.0/template/variables/searchusingresponse_content: METADATA_AND_VALUES, and inspect the generated SQL. docs - 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)
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
runtimeFiltersonSpotterEmbed(SDK 1.41.0 / TS 10.13.0.cl) persist across a conversation, but remain context, not security. docsCAN_USE_SPOTTERgates access to Spotter.CAN_MANAGE_SPOTTERdoes 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.
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.
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
| Setting | What it does | Level | Watch for |
|---|---|---|---|
| CSP visual embed hosts | Sets 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 allowlist | Which origins may call ThoughtSpot APIs from the browser. | Cluster, overridable per Org | Wildcard prefixes are not supported for CORS. CSP and CORS lists together are limited to 4096 characters. No query strings, no semicolons. |
| SAML redirect domains | Allowed redirect targets for SAMLRedirect. Enter domains without protocol, comma-separated. REST: cluster_preferences.saml_redirect_urls[]. | Cluster only | Needed only if you use SAMLRedirect. Leave empty otherwise. |
| Block non-embed full app access | Stops 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 Org | Fits 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
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',
});
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 path | UI control | Real enforcement |
|---|---|---|
| Raw data download (CSV, XLSX) | Hide Action.Download, Action.DownloadAsCsv, Action.DownloadAsXlsx | Withhold CAN_DOWNLOAD_DETAILED_DATA |
| Image and PDF export | Hide Action.DownloadAsPng, Action.DownloadAsPdf | Withhold CAN_DOWNLOAD_VISUALS |
| Schedules and sharing | Hide Action.Schedule, Action.Share | Role privileges such as JOBSCHEDULING ("Can schedule for others"), plus content sharing rules docs |
| Export or import of TML | Hide Action.ExportTML, Action.ImportTML | Keep 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
- Token side effects such as
auto_createand 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/customsets 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 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.
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.
| Decision | Fits when | Costs | Risk | What you give up |
|---|---|---|---|---|
| Block non-embed access | No user needs the full ThoughtSpot UI | Selective access setup for exceptions | Off: 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 privileges | Data must not leave through files | Role design, and users lose a feature they may expect | Granted: users can export raw data. Status of granular privileges conflicts across docs, see the action surface panel | Self-service export |
| Short token lifetime | Tokens might be exposed to scripts or logs | More mint calls, so more load on your token service | Long: wider replay window. Short: refresh failures can interrupt users | Simplicity |
| Cookieless auth | Browsers block third-party cookies (the docs reason) | Your getAuthToken must return a fresh token on each call | Cookie-based: third-party cookie blocking can break sessions | Little, per the docs we checked |
auto_create | Users appear first at login and you can govern that creation | Governing who can be created | On: a token request can create users, so the secret_key matters more | Control over creation timing |
| Per-Org secret key | Org per customer, and you want key separation | Contact Support to enable (docs); more keys to vault | One shared key: one leak affects every Org that uses it (our reading) | Fewer secrets |
Privileges worth governing
| Privilege | What it allows (docs) |
|---|---|
ADMINISTRATION | Required 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 |
DEVELOPER | Alongside ADMINISTRATION, allows calling the security-settings configure endpoint (REST reference). Developer users are not restricted by the non-embed block. |
BYPASSRLS | Create, edit or delete RLS rules, and enable or disable Bypass RLS on a Model. docs |
CAN_MANAGE_VARIABLES, CAN_CREATE_OR_EDIT_CONNECTIONS | Manage 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.
SDK lifecycle and events
When to call what, in what order, and how your page and the embedded iframe talk without racing.
- init once
- Create the embed and subscribe
new LiveboardEmbed(el, cfg), then attach.on(EmbedEvent.Error, ...)and your other handlers. RegisterEmbedEvent.Errorbeforerender(). docs.on()is chainable. - render
render()returns a promise for the embed. Treat it as “iframe mounting”, not “content ready”. Callingtrigger()beforerender()returnsnull. docs - Init and AuthInit
Initfires at the start of loading.AuthInitfires on successful SSO authentication and carries{ data: { userGuid } }. docs - Load
Loadfires when the area for embedding is created, not when content has begun or finished loading. Do not hide your spinner here. docs - Better readiness signalsUse
LiveboardRendered(LiveboardEmbedonly, 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 asSpotterLoadComplete(conversation created) orSpotterResponseComplete(response finished streaming). docs - Subscribed
- trigger returns a promise
- 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 host events after Subscribed, and destroy before you re-render.
Iframe to you
Subscribe with embed.on(EmbedEvent.X, handler) to react to loads, errors, clicks, saves, filter changes and custom actions.
You to iframe
Send with embed.trigger(HostEvent.X, payload) to change filters, switch tabs, start a search, export or navigate.
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, andNoCookieAccessexplicitly.
- Our guidance: keep
init()out of components that re-render. The docs’ React samples call it at module level. - Do not
destroy()on everyEmbedEvent.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
Loadas “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
- Inventory every
embed.trigger(HostEvent.*)call in your code, including ones in shared wrappers. - In dev and staging only, set
useHostEventsV2: truein the embed’s view config (for example the options you pass tonew LiveboardEmbed()); it is not aninit()option. SDK 1.45.2+ · TS 26.3.0.cl+ docs - Run your flows and watch
EmbedEvent.Errorfor payload validation failures (codeHOST_EVENT_VALIDATION). v2 validates payloads strictly. docs - 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 aContextTypeas the thirdtrigger()argument where needed. docs - 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.
| Option | Fits when | What it costs | Risk | What changes the answer |
|---|---|---|---|---|
Opt in now (useHostEventsV2: true) | You have test coverage over your host events and want strict validation early | Migration work now; payload fixes | The routing page still labels v2 beta, so behavior may still change | A staging cluster on the same release as production |
| Wait for the default to change | Few trigger() call sites, or a release freeze | Lower effort now; a larger one-step change later | The 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
| Event | Direction | When it fires or what it does |
|---|---|---|
EmbedEvent.Init | iframe to you | Start of load |
EmbedEvent.AuthInit | iframe to you | Successful SSO; payload has userGuid |
EmbedEvent.Load | iframe to you | Embed area created, not content loaded |
EmbedEvent.LiveboardRendered | iframe to you | Liveboard or viz container (shell) loaded (LiveboardEmbed only) |
EmbedEvent.Subscribed | iframe to you | A host-event listener is registered; safe to send host events |
EmbedEvent.Error | iframe to you | SDK, auth, API, validation errors; register before render() |
EmbedEvent.AuthExpire | iframe to you | Session timeout |
EmbedEvent.NoCookieAccess | iframe to you | Third-party cookies blocked |
EmbedEvent.Data, VizPointClick, CustomAction | iframe to you | Data returned, user clicks a point, custom action invoked |
EmbedEvent.FilterChanged, ParameterChanged | iframe to you | User changed a filter or parameter |
HostEvent.UpdateRuntimeFilters, UpdateFilters, UpdateParameters | you to iframe | Change filters or parameters; see Filters |
HostEvent.GetFilters, GetParameters, GetTabs | you to iframe | Read state; the result comes back in the promise |
HostEvent.SetActiveTab, Navigate, Search | you to iframe | Switch tabs, navigate, run a search |
HostEvent.DestroyEmbed | you to iframe | Tells 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
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
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
| Signal | Meaning | The docs’ recovery action |
|---|---|---|
EmbedEvent.Error with { error } | SDK or configuration error | Fix the configuration, recreate and render the embed |
AuthStatus.FAILURE | Token or login failed in the SDK layer | Check getAuthToken and credentials; suppressErrorAlerts for your own UI |
EmbedEvent.AuthFailure | Auth failed in the iframe | Show a login prompt, recreate and render |
EmbedEvent.AuthExpire, IdleSessionTimeout | Session timed out | autoLogin: true for a transparent refresh, or prompt re-authentication |
EmbedEvent.NoCookieAccess | Third-party cookies blocked | Switch to cookieless trusted auth, or ignoreNoCookieAccess: true to suppress the alert |
| Duplicate token | getAuthToken returned a reused token | Always return a fresh token |
{ offlineWarning } | Browser went offline | Show offline UI, reconnect and reload |
HOST_EVENT_VALIDATION | Your host-event payload failed validation | Fix the payload; use useHostEventsV2: true in development |
trigger() resolves with an Error (30 s) | Host event timed out | Check 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
| Capture | Source | Why |
|---|---|---|
code, errorType, message | EmbedErrorDetailsEvent, enums EmbedErrorCodes, ErrorDetailsTypes docs | Machine-readable categories for dashboards and alerts |
severity (SEV1 cannot render, SEV2 degraded, SEV3 everything else, including validation of your own input) New | The 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. docs | Separates 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 above | Tells auth failures apart from render failures |
Init, Load, LiveboardRendered timestamps | Lifecycle above | Our guidance: a load-time metric per embed |
logLevel in init() | Default LogLevel.ERROR docs | Controls 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 release | Recommended SDK |
|---|---|
| 26.4.0.cl | 1.47.0+ |
| 26.5.0.cl | 1.48.0+ |
| 26.6.0.cl | 1.49.0+ |
| 26.7.0.cl | 1.50.0+ |
| 26.8.0.cl | 1.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.
| Delivery | Fits when | What it costs | Risk |
|---|---|---|---|
| npm with a lockfile | You have a build and a release process | You bump the version yourself | Falling behind the cluster’s recommended version |
| Vendored file (self-hosted copy) | You need a fixed artifact or a strict CSP | You refresh and host it | Same as above, plus a stale copy nobody owns |
| Unversioned CDN URL (the getting-started sample) | Prototypes and demos | Least effort; you receive whatever is newest | Behavior 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.
| Option | Fits when | What it costs | Risk or what you give up |
|---|---|---|---|
Plain classes (new LiveboardEmbed()) | Any framework, or none | You write the lifecycle helper | A 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 docs | The pages reviewed do not describe behavior on prop changes or double mounts. Test re-render and unmount paths yourself. |
| Your own thin wrapper | Several teams or frameworks share one standard | You maintain it | You 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
| Strategy | Perceived speed | Cost |
|---|---|---|
| Normal render | Slowest | No reuse; no wasted loads |
| Prefetch | Slight gain | Browser cache often makes it redundant |
| Pre-render with Liveboard ID | Fastest | Loads data and assets even if never opened |
| Pre-render without ID | Moderate | Loads some assets unused; data waits until needed |
| On demand | Fast on revisit, slow on first visit | The 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
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.
Pick by intent, and remember that none of the four is a security boundary. Use row-level security for that. docs
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
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: [].
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.
| Kind | API | Visible to user? | Persistence | Security |
|---|---|---|---|---|
| Runtime filters | View config runtimeFilters; HostEvent.UpdateRuntimeFilters docs | Not 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 parameters | View config runtimeParameters; HostEvent.UpdateParameters docs | Hidden 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) filters | HostEvent.UpdateFilters, HostEvent.GetFilters docs | Yes, as filter chips | Chips 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 filters | The filter UI inside the embed (chips, cross filters, search filters) | Yes | Depends 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 |
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:
| Group | Members |
|---|---|
| Equality | EQ, NE |
| Comparison | LT, LE, GT, GE |
| Set membership | IN, NOT_IN |
| Range | BW (non-inclusive), BW_INC (inclusive of both ends), BW_INC_MAX (inclusive of the higher value), BW_INC_MIN (inclusive of the lower value) |
| Text | CONTAINS, 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 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
excludeRuntimeFiltersfromURLflag 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
runtimeFiltersproperty 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.Errorwith typed errors such asNON_EXIST_FILTER,INVALID_DATE_VALUEandINVALID_OPERATOR, and invalid host-event payloads through codes such asUPDATEFILTERS_INVALID_PAYLOAD. Log them with the filter you sent, as in the lifecycle observability table. docs docs - On
UpdateFilters, an emptyvaluesarray 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, orisVisibleToUser: 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) UpdateFiltersuses date types such asEXACT_DATE,EXACT_DATE_RANGE,LAST_N_PERIOD, withYYYY-MM-DDorYYYY/MM/DDstrings, 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
UpdateFilterswhen 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
UpdateRuntimeFiltersto 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.
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.
They look alike to a user and share almost no code, so decide which one you want first.
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.
- The user left-clicks a markYou subscribed to
EmbedEvent.VizPointClickGA SDK 1.11.0+ · TS 8.3.0.cl+ docs. It carriesvizIdandclickedPoint. 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). - Your app asks what the user already filteredTrigger
HostEvent.GetFiltersand 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. - The embed returns the board's filtersOptional: if the call fails on your build, fall back to the clicked point alone.
- Destroy, then open the second LiveboardAlways
destroy()first. Pass the merged filters asruntimeFiltersdocs, and add a Back button in your page.
Right-click: your own detail panel
Nothing reaches your app until the user picks your menu item. Then your server queries the records.
- You declare the action when you build the embedA code-based custom action with
CONTEXTMENUandCustomActionTarget.VIZSDK 1.43.0+ · TS 10.14.0.cl+ docs. - The user right-clicks a valueThoughtSpot's menu opens with your item appended. Nothing reaches your app yet Field-observed.
- The user picks your item:
EmbedEvent.CustomAction - Your server calls
searchdataPOST /api/rest/2.0/searchdatadocs, available from TS 9.0.0.cl+. Build the query on the server, filtered by every attribute on the row. - ThoughtSpot returns the matching rowsIt runs as the token's user, so row-level and column-level security apply. With
COMPACT, each row is an array aligned tocolumn_namesField-observed. - Your panel rendersOutside the iframe. Use
textContentfor every value and allow onlyhttpandhttpslinks.
What arrives with the click
selected* is the cell that was clicked, deselected* is the rest of its row. Read both.
A measure-cell click leaves selectedAttributes empty. The row's attributes land in deselectedAttributes Field-observed.
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.
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.CONTEXTMENUis valid forVIZ,ANSWERandSPOTTER, notLIVEBOARDdocs.- Read the action id with
payload?.id ?? payload?.data?.id: classic Liveboards usepayload.id, the new experiencepayload.data.iddocs.
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.vizIdsalone. The docs describe the scope keys as a union docs. - Carry every attribute on the row, from both buckets.
- Build
query_stringon the server from an allowlist of columns. - Call
searchdatawith 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
textContentand allow onlyhttp(s)links.
- Do not set
vizIdsandmodelColumnNamestogether and expect AND. - Do not read only
selectedAttributes. - Do not treat
available_data_row_countas 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_VALIDATIONas 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.
- Defaults and ceiling. The data APIs default to
record_size10 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_sizeto-1. The docs warn of slow responses and out-of-memory failures docs.
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_countis 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. GetFiltersshape. 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 usecolumn, notcolumnName. The code above accepts both envelopes (array and{ liveboardFilters }) and both item keys; verify on your cluster.deselectedAttributesshape. The docs sample shows a single object; field notes show an array of{ column, value }. The helper accepts both.contextMenuPointsshape. 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.dailykeyword 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
| Pattern | User gesture | SDK pieces | Leaves the iframe? | Who renders the detail | Row-level security |
|---|---|---|---|---|---|
| Native drill down | Right-click, then Drill down (or a double-click you wire yourself) | Action.DrillDown, HostEvent.DrillDown, disabledActions / hiddenActions | No | ThoughtSpot | Applied by ThoughtSpot |
| Left-click to a second Liveboard | One left-click on a mark or cell | EmbedEvent.VizPointClick, HostEvent.GetFilters, runtimeFilters | No (no REST call) | ThoughtSpot renders the Liveboard; you render a Back control | Applied by ThoughtSpot; the runtime filters you pass are not a boundary |
| Right-click to your panel | Right-click, then pick your menu item | customActions, EmbedEvent.CustomAction, POST /api/rest/2.0/searchdata | Yes, one REST call from your server | You render everything | Applied if you call as the viewer; a service token would bypass it |
You can run all three on the same Liveboard.
| Pattern | When it fits | What it costs | Row volume and performance | Export and compliance | What you give up |
|---|---|---|---|---|---|
| Native drill down | A finer breakdown is enough | No build, no upkeep | Handled by ThoughtSpot | Same privileges and RLS as the rest of the embed | Custom layout and row links |
| Second Liveboard | A designed detail view with charts | One handler, a detail Liveboard to maintain, column names kept in step | Aggregated viz queries, no raw-row paging of yours | Filters are visible and editable by the user | Raw rows and row links |
| Your own panel | Row links, your styling, or a controlled record list | Server route, allowlist, paging, UI, upkeep across SDK and cluster upgrades | One warehouse query per page; you set the page size and the ceiling | A new place where raw records are shown; you own logging and any copy or export buttons | Simplicity |
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.
hiddenActionsremoves items,disabledActionsgreys them out,visibleActionsis an allowlist. Set either hidden or visible, not both docs. - Trigger it yourself.
HostEvent.DrillDowntakespoints(clickedPoint,selectedPoints) and either acolumnGuidorautoDrillDown(the most popular column).vizIdis required in a Liveboard context TS 9.8.0.cl+ docs. - Listen for it.
EmbedEvent.Drilldownfires 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
});
});
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
VizPointClickis 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.
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.CustomActionarrives. - Native actions stay available.
- Leaves the iframe: yes, one REST call. You render the whole panel.
Show underlying data or your own panel? The trade-offs
| ThoughtSpot Show underlying data | Your own panel (searchdata) | |
|---|---|---|
| Build and upkeep | None. Action.ShowUnderlyingData is a menu action docs | Server route, UI, paging, upgrades |
| Listening and triggering | EmbedEvent.ShowUnderlyingData fires on click docs; HostEvent.ShowUnderlyingData opens it docs | You choose the trigger and the layout |
| Controls | Hide or grey it out per embed (cosmetic) | Your allowlist, row cap, logging and copy rules |
| Auditing who saw rows | No documented hook beyond the event above. Unverified: whether ThoughtSpot logs the view | Your 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
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,
searchdataalso requires theCAN_DOWNLOAD_DETAILED_DATAprivilege, 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,
DATADOWNLOADINGceases 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
searchdataand 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.SPOTTERwithMENUorCONTEXTMENU, butmodelColumnNamesis not supported for that target docs. For native drill from a Spotter answer,HostEvent.DrillDownneeds the Answer ID asvizIddocs. - 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
MENUorPRIMARYpositions, 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.
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.
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
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?
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 action | Callback action | |
|---|---|---|
| What happens | ThoughtSpot POSTs Answer or viz data to your URL | ThoughtSpot emits EmbedEvent.CustomAction to your app |
| How you define it | ThoughtSpot UI only | UI or SDK code (customActions) |
| Licence | No embedded licence needed; needs admin or developer privilege | Callbacks need a ThoughtSpot Embedded licence docs |
| Runs your code | No, your server receives a POST | Yes, in the host page |
| Fits when (our guidance) | A fixed hand-off to a service you already run, no host code | You need the user’s session, UI, or a backend re-check |
| What it costs | Admin-managed in the UI; not portable as code; credentials stored in ThoughtSpot | Handler code to build, secure and monitor; licence |
| Main risk | Credentials are visible to other authenticated users, so a powerful secret leaks docs | A 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.
| Target | PRIMARY | MENU | CONTEXTMENU | Scope fields |
|---|---|---|---|---|
LIVEBOARD | Yes | Yes | Not supported | metadataIds.liveboardIds, orgIds, groupIds |
VIZ | Yes | Yes | Yes | metadataIds (answerIds, liveboardIds, vizIds), dataModelIds (modelIds, modelColumnNames), orgIds, groupIds |
ANSWER | Yes | Yes | Yes | metadataIds.answerIds, dataModelIds (modelIds, modelColumnNames), orgIds, groupIds |
SPOTTER | No | Yes | Yes | dataModelIds.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.
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.
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.Errorcodes such asCUSTOM_ACTION_VALIDATIONandCONFLICTING_ACTIONS_CONFIGdocs. 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_VALIDATIONis not fatal. If you routeEmbedEvent.Errorto a global error handler, filter it out Field-observed.answerServicedoes not serialize. It is a live object, so it will not appear inJSON.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.idandpayload.data.id. - Scope with
vizIdsalone, orliveboardIdsfor 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
localhostas 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.
Look and feel: styling, text, icons, and hiding UI
Make the embed look like your product without it breaking on the next ThoughtSpot upgrade.
customCSS.variables: supported contractThe 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' },
},
});
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' }
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
| Need | Layer | Stability | What it costs or what you give up |
|---|---|---|---|
| Colors, fonts, buttons, root background | customCSS.variables | Documented surface; the docs give no compatibility guarantee either way | Only what a variable exposes |
| Chart legend, labels, axes | visualOverrides | New in SDK 1.49.0 | New Answers only; needs SDK 1.49.0 and TS 26.6.0.cl |
| Rename a specific label | content.stringIDs | Exact | You must look up each ID (exposeTranslationIDs) |
| Swap icons | iconSpriteUrl | Depends on icon IDs | You host the sprite; each ID must match a built-in icon |
| Rename text everywhere | content.strings | Broad, can corrupt labels | Hits every case-sensitive substring |
| Hosted CSS file | customCSSUrl | Same as what you put in it | You 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_UNSTABLE | Can break on upgrade | A re-test on every ThoughtSpot release; !important often needed |
| Remove a button or menu item | visibleActions / disabledActions / hiddenActions | UI only | Not 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 allowexposeTranslationIDsonly for admin or developer users. docs
- Do not use
stringsfor 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
| Setting | Where | What it does |
|---|---|---|
locale | Embed view config (AppEmbed, LiveboardEmbed, SearchEmbed, SpotterEmbed and others) SDK 1.9.4 · TS 8.1.0.cl | Language 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, currencyFormat | init() SDK 1.28.4 · TS 10.0.0.cl | Override formats independent of the language docs |
preferred_locale, use_browser_language | REST user create, update, import | Per-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.
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
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.
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.
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 asksThe question arrives through the embed, the MCP server, or a REST
/sendcall. Either way, the caller's identity is the user's token. - Model selectionWith a model GUID it uses that model. In auto mode on Spotter 3 it discovers the data model for you docs.
- Query generated against the ModelSpotter turns language into a query using your column names, descriptions, synonyms, instructions, and coaching. This is why the Model matters.
- RLS and CLS appliedSecurity rules run on the query for that user. Spotter, the MCP server, and the REST APIs all enforce them docs.
- Answer returnedYou get an answer visualization. Over MCP the default response includes a title, the search query, an embeddable
iframe_url, and ananswer_iddocs. - Follow-upThe next message goes to the same conversation. Save chats with
enable_save_chat(26.5+, default false) on the REST create call docs.
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
| Option | UI control | Build effort | Governance | Status risk | What you give up |
|---|---|---|---|---|---|
SpotterEmbed | Low: styling and actions | Lowest | Privileges and coaching apply as in the product | Spotter 3 is Early Access (below) | Custom chat UX |
Spotter in AppEmbed | Low | Low | Same; more of the app is exposed | Newer options need recent clusters | A focused Spotter-only screen |
| Agent conversation REST | Full: your UI | High: streaming, rendering, retries | Runs as the user's token; per-user rate limits apply docs | GA from 26.5.0.cl | Embed features you must rebuild (our guidance) |
| Spotter MCP server | Full, or the client's own UI | Medium: an MCP client and an LLM | RLS and CLS enforced; you also govern the LLM and client you connect docs | Status not stated in docs | Tool-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.
| Option | You get | Status |
|---|---|---|
SpotterEmbed | Full chat UI in an iframe | GA replaces ConversationEmbed docs |
Spotter in AppEmbed | Spotter plus the rest of the app | Supported; the docs give no separate status |
SpotterAgentEmbed | Headless chat via sendMessage | Docs: will be deprecated in an upcoming version docs |
| Agent conversation REST | Your UI, Spotter 3 behind it | GA |
Legacy REST AI (createConversation, sendMessage, singleAnswer) | Spotter Classic over REST | Beta |
| Spotter MCP server | Remote MCP tools for any client | Status 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
| Option | Version | Notes |
|---|---|---|
spotterSidebarConfig | SDK 1.47.0 · TS 26.4.0.cl | Supersedes the standalone enablePastConversationsSidebar flag and takes precedence docs. |
spotterChatConfig | SDK 1.46.0 · TS 26.4.0.cl | Brands tool-response cards. starterPrompts need SDK 1.53.0 (TS 26.10.0.cl); spotterFileUploadEnabled (default false) needs SDK 1.49.0 docs. |
defaultQueryMode | SDK 1.52.0 · TS 26.9.0.cl | SpotterQueryMode.FAST_SEARCH or SpotterQueryMode.RESEARCH; applies per session and is not persisted as a user preference docs. |
dataSources | SDK 1.52.0 · TS 26.9.0.cl | Array of model GUIDs, preferred over worksheetId. The docs say the feature is behind a feature flag docs. |
runtimeFilters | SDK 1.41 · TS 10.13.0.cl | Pass filters into Spotter. Not a security boundary docs. |
enableStopAnswerGenerationEmbed | SDK 1.48.0 · TS 26.5.0.cl | Stop button docs. |
spotterAnalystConfig | SDK 1.53.0 · TS 26.10.0.cl | spotterAnalystConfig.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.
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 useSpotterVizStarterPrompt) 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.InConversationTrainingdocs. - 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 needCAN_MANAGE_SPOTTERplus edit access orSPOTTER_COACHING_PRIVILEGEon the model docs. - Privileges:
CAN_USE_SPOTTERto use it;CAN_MANAGE_SPOTTERfor global coaching and instructions (it does not grant model editing) docs. Delegate access withSPOTTER_COACHING_PRIVILEGE(TS 26.3.0.cl) docs.
- Our guidance: improve the Model and coaching before tuning prompts.
- Use
EmbedEvent.Subscribedbefore sending host events at load (SDK 1.48.0 · TS 26.4.0.cl) docs. - Use
conversation_identifier, notconversation_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
runtimeFiltersin Spotter to restrict data. Use RLS. - Do not set the deprecated
enable_contextual_change_analysis,enable_natural_language_answer_generation, orenable_reasoningconversation 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
| Control | What the docs state | Choice or owner (our guidance) |
|---|---|---|
| Who may use Spotter | CAN_USE_SPOTTER privilege docs | Grant by group so access is reviewable. A broad grant is simpler; a narrow grant costs admin time. |
| Who may change behaviour | CAN_MANAGE_SPOTTER for global coaching and instructions. Instructions add business context and do not train or change the LLM docs | Name a model owner. Central ownership is consistent but slow; letting data owners coach is faster but harder to audit. |
| Audit AI answers | Saved 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 docs | This 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 LLMs | The developer docs searched do not state a data-handling position. | Unverified. Ask ThoughtSpot for its written statement before a regulated rollout. |
| Throttling | Per user per minute: 10 conversation creates, 30 sends. Plan retry logic docs | A 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.
Exports, scheduling, and delivery
Get PDFs, images, and data files out of ThoughtSpot, from the browser or from your server.
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
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.
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
| Option | When it fits | What it costs | Risk | What you give up |
|---|---|---|---|---|
| On-demand, client-side | Users export what they see | Little; privilege setup | Anyone with the privilege can take the data out | Automation, archives |
| On-demand, your backend | You must log, watermark or archive each export | A service plus token handling | A shared token can bypass per-user RLS | Simplicity |
| Schedule, email | Recurring digests to known readers | Lowest | Files leave your custody; recipients can be non-users | Control over the copy |
| Schedule, webhook and bucket | Your pipeline must own the file | An endpoint, HMAC checks, a bucket (S3 or GCS) | Your storage becomes the exposure | Setup speed |
- Recipients outside the tenant. Schedules take ThoughtSpot users or groups as
principals, plusemailsfor non-users docs. Webhook payloads mark recipients asUSERorEXTERNAL_EMAILdocs. 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 needsJOBSCHEDULINGorADMINISTRATION. Whether a schedule keeps running after its author loses access is not stated. Inventory schedules with/schedules/searchand 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_DATAfor CSV and XLSX,CAN_DOWNLOAD_VISUALSfor 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
| Question | Client-side | Server-side |
|---|---|---|
| User clicks a button in your UI | Yes | Via your backend |
| Attach to an email or store in an archive | No | Yes |
| Include unsaved Liveboard changes | Yes | Yes, with transient_content |
| Runs on a timer | No | Yes, or use schedules |
| Respects the viewer's RLS | Yes | Only 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.
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
| Feature | Status | Details |
|---|---|---|
| Continuous PDF (REST) | Beta | pdf_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) | GA | isContinuousLiveboardPDFEnabled: 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 PNG | Beta | png_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 embed | GA | isLiveboardXLSXCSVDownloadEnabled 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. |
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
DATADOWNLOADINGprivilege governs exports. - With RBAC, CSV and XLSX need
CAN_DOWNLOAD_DETAILED_DATA; PNG and PDF needCAN_DOWNLOAD_VISUALSdocs. 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.DownloadLiveboardAsCsvdocs. 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:
descriptionis mandatory.file_formatis CSV, PDF, or XLSX (default PDF).- The schedule's PDF
page_sizeenum isA4orTAB_BASED. It has noCONTINUOUS, unlike the report API. - On a mixed chart-and-table Liveboard a CSV schedule fails; use PDF or XLSX. Omit
pdf_optionsand the PDF attachment may be empty docs. - You need edit access, and
ADMINISTRATIONorJOBSCHEDULINGto schedule for others (with RBAC,JOBSCHEDULING). Default time zone is America/Los_Angeles docs.
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_formatand 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_filtersor 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.
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.
report/liveboard returns the file.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?
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.
| Option | When it fits | What it costs | Risk | What you give up |
|---|---|---|---|---|
| Built-in schedule, email | Fixed content to known readers, such as an internal digest | Lowest: one API call per schedule | Files leave your custody; whose RLS a recipient's copy uses is not stated in the docs searched | Per-run values, event triggers, your own delivery |
| Built-in schedule, webhook or bucket | A pipeline must own the file (archive, SFTP push, warehouse load) | An endpoint with HMAC checks, or an S3 or GCS bucket | Your storage becomes the exposure; one webhook per Org | Per-run values; the schedule still decides content |
| Your own scheduler | Per-run filters, event triggers, per-customer bindings, your own delivery and audit | A scheduler, a worker, a queue, token minting, storage | A shared token can bypass per-user RLS; you own retries and failures | ThoughtSpot'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/searchcan return recent runs withinclude_history_runsdocs. With your own scheduler, run history is yours to build. - Webhook payload. A multipart request with the file and a JSON payload;
msgUniqueIdcan 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.
| Audience | When it fits | What it costs | Risk | What you give up |
|---|---|---|---|---|
| One export per user schedule | Each person's file must show only their rows | Jobs grow with users x reports; one token per run | Lowest data risk; highest load and the most schedules to inventory | Run volume |
| One export per tenant, shared by its users | Everyone in a tenant is entitled to the same rows | Jobs grow with tenants; a token that reflects the shared access | If users in a tenant differ in access, someone sees too much | Per-user precision |
| One export for all users | No row-level security applies to the data | Lowest | A later RLS rule on the table silently changes who should see what, and the shared file ignores it | Any per-audience difference |
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, orCAN_DOWNLOAD_DETAILED_DATAfor CSV and XLSX andCAN_DOWNLOAD_VISUALSfor PDF and PNG under granular RBAC) docs. - In a multi-Org setup the custom token takes
org_identifierto 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 case | Often fits | Format and options | Watch for |
|---|---|---|---|
| Customer-facing statements | Your scheduler, per user or per tenant | PDF. pdf_options offers cover page, custom logo, page numbers, footer text, orientation; regional_settings sets currency and locale docs | Files leave your control once sent; link access, retention and naming are yours |
| Event-triggered (month-end close) | Your scheduler | Any. Resolve "latest close date" to a concrete value when the event fires | The built-in schedule takes a cron only, so an event needs your own trigger |
| Bulk multi-tenant fan-out | Your scheduler with a queue | Same Liveboard, one token and one set of bindings per tenant | One 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 scheduler | Liveboard 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 docs | Export is for reading; large feeds need paging with an explicit record_size, never -1 docs |
| Internal executive digest | Built-in schedule to a group | Check what each recipient's copy shows if RLS applies | |
| Anything sub-hourly | Support request, or your scheduler | Any | Minute-level schedules were deprecated at 26.8.0.cl |
What will this schedule run with?
override_filters. Otherwise runtime_filter.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.
| Binding | Goes into | Effect |
|---|---|---|
| Column with a saved Liveboard filter | override_filters[] New | Replaces filters on the same column |
| Column without a saved Liveboard filter | runtime_filter | Adds a condition |
| A Parameter | runtime_param_override | Sets the Parameter value |
| "All", column without a saved filter | Leave it out | No condition |
| "All", column with a saved filter | Not documented | See the warning below |
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,YESTERDAYandLAST_N_PERIODwithdatePeriodandnumber. - 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,.pngor.csvyourself. A multi-visualization CSV is a ZIP (26.2.0.cl+) docs. Always passfile_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/answerexports an Answer withruntime_filterandruntime_param_override, and names the file after the Answer title docs.
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.
| Source | What you get | What it costs | Caveat |
|---|---|---|---|
TML exportPOST /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 docs | Needs 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 GAPOST /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.GetFilters | The 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 docs | A 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.FilterChanged | An event when a user changes a filter, with filter type and name, column name and ID, and runtime filter details docs | Low, 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.
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/createhas no filter field, as covered above. The one handle on content ispersonalised_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_detailsandinclude_personalised_views. You getview_guid,view_name, author andis_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_filterswith 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, includingpersonalised_view_idandinclude_filter_pagedocs. 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.
| Option | When it fits | What it costs | Risk | What you give up |
|---|---|---|---|---|
| Read once at registration | Liveboards that rarely change, few reports | Lowest: one call per report | A 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 compare | Reports that must not fail or drift silently | One extra call per run; storing the last snapshot | Low. A diff can pause the run and alert the owner | Some run time and REST budget, which matters in a fan-out |
| Re-read on a schedule (for example daily) and flag drift | Many reports on a few Liveboards | Calls grow with Liveboards, not runs | A window between a change and the next check | Immediate detection |
| Re-read when the Liveboard changes | Would be ideal | Not available in the docs searched: no change event or webhook found | Do not design around it until it is documented | Not applicable |
Always override_filters (if your POC proves it) | You control all the filters in the report anyway | Lowest: no reading, no diff | Behaviour on unfiltered columns is undocumented; verify per release | Doc-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 | |||
|---|---|---|---|
| Column | Saved on the Liveboard | Your binding | Sent as |
| Region | IN West, East | = East | override_filters (replaces) |
| Order Date | Last 3 months | Latest close month | override_filters (date, one object) |
| Segment | none | = Retail | runtime_filter (adds) |
| Channel | IN Web, Store | no binding | Saved 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_filteron 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.
Performance: faster embed loads
See where the time goes when an embed loads, and move it off the user's critical path.
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
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
| Strategy | Speed on view | Cost | Use when |
|---|---|---|---|
| Normal render | Slowest; nothing reused | None extra | Rarely visited pages |
| Prefetch | Small gain | Few static assets | Cheap warm-up; the strategy table notes that browser cache often makes it redundant |
| Pre-render with Liveboard ID | Fastest | Most resource-intensive; loads data even if never viewed | The landing analytics page everyone opens |
| Pre-render without Liveboard ID | Fast shell, short loading state for data | Common assets and bootstrap only | Many possible Liveboards, unknown which |
| On demand | First visit normal, revisits instant | Lowest waste | Best 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-level | Use instead |
|---|---|
preRenderId Deprecated | preRenderConfig.id |
preRenderContainer Deprecated | preRenderConfig.containerSelector |
doNotTrackPreRenderSize Deprecated | preRenderConfig.doNotTrackSize |
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
liveboardIdand the same layout and behavior flags (for exampleisLiveboardCompactHeaderEnabled,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
});
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
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
| Choice | When it fits | What it costs | Risk | What you give up |
|---|---|---|---|---|
| ID-based pre-render | One view nearly everyone opens | Queries and browser resources for every session; the docs call it the most resource-intensive | Wasted load when users never look | Warehouse headroom |
| On-demand | Revisits are common | Normal first load | Slow first view | First-visit speed |
| Warehouse-side caching | Many users hit the same data | Warehouse tuning, which the docs list as a lever docs | Staleness depends on warehouse settings | Real-time numbers |
| Browser data cache (Early access) | Repeat opens of the same board | Support enablement | Stale numbers per user | Freshness 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: trueininit()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 withvizIdonly) 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
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.
- Build in dev and tag the releaseAll authoring happens in the dev Org. Mark the objects that make up the release, including the Models and Tables a Liveboard depends on. docs
- Export the TML to a release branch
POST /api/rest/2.0/metadata/tml/exportwrites one file per object. Each file carries itsobj_id, so it can be imported into any Org. docs - Open a pull request to the test branchThe 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
- Validate, then import into testRun the import with
import_policy: VALIDATE_ONLYfirst, then import for real. Import matches onobj_id: it updates an object that already exists and creates one that does not. - Test and sign offTesters work in the test Org. Nobody edits content there; a fix goes back to dev and comes through the flow again.
- Pull request to the prod branchApproving 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.
- Validate, then import into prodThe 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
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
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
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
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.
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.
| Option | How it works | Trade-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 docs | One set of TML for every environment. Variables must be created and assigned in each Org first. |
| GUID mapping find/replace | With ThoughtSpot's Git integration, the mapping file can rewrite strings such as db: DEVDB to db: PRODDB during deploy. docs | Exact 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 Org | Each 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
| Option | When it fits | What it costs | Risk / 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.docs | GitHub and GitHub Enterprise only. Sequential multi-Org deploys. |
| TML export/import scripts with any Git provider | Another Git host, or you want your own release package and filtering. The docs say this gives more flexibility for new setups.docs docs | You 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 UI | One-off moves or early prototypes. | No pipeline to build. | Our reading: no review trail or repeatability unless you add them yourself. |
| Tool | What it does | Watch for |
|---|---|---|
POST /api/rest/2.0/metadata/tml/export | Exports objects as TML (JSON default, or YAML). export_fqn adds FQNs so same-name objects are unambiguous. Needs DATADOWNLOADING. docs | With export_associated, consider fetching one object at a time. YAML TML needs manual formatting (a known issue). |
POST /api/rest/2.0/metadata/tml/import | import_policy is PARTIAL (default), ALL_OR_NONE, VALIDATE_ONLY or PARTIAL_OBJECT. Needs DATAMANAGEMENT or ADMINISTRATION. docs | Import 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/create | Links an Org to a GitHub or GitHub Enterprise repo. enable_guid_mapping defaults to true. docs | Needs an access token with repo scope and the administration privilege (or CAN_SETUP_VERSION_CONTROL under RBAC). |
POST /vcs/git/branches/commit | Commits TML from an Org to a branch. docs | Commit 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/deploy | Deploys a branch into the linked Org. deploy_type: DELTA (default) or FULL. deploy_policy: ALL_OR_NONE (default), PARTIAL, VALIDATE_ONLY. docs | Deploys the branch head unless you pass commit_id. Pass a tag's commit SHA for releases. |
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_PRIVILEGEcan import and export coaching TML on the data model (26.3.0.cl+).docs - Personalized Views: import with
enable_personalized_view_upsertto update byobj_idinstead 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/exportand/ai/memory/import(26.8.0.cl). Import replaces the memory of the target data models rather than appending, so validate withdry_runfirst.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
| Option | When it fits | What it costs | Risk / what you give up |
|---|---|---|---|
| Exact npm version plus lockfile | You 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 tests | You 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 import | Prototypes 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 release | Recommended SDK | Notes |
|---|---|---|
| 10.15.0.cl (Jan 2026) | 1.44.0+ | No breaking changes. |
| 26.2.0.cl | 1.45.0+ | API parameter deprecations. |
| 26.3.0.cl | 1.46.0+ | Feature deprecations. |
| 26.4.0.cl | 1.47.0+ | Variable API and metadata parameterization endpoints deprecated. |
| 26.5.0.cl | 1.48.0+ | Breaking changes to Spotter APIs. |
| 26.6.0.cl | 1.49.0+ | No breaking changes. |
| 26.7.0.cl | 1.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.
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.
| Concern | Options | Trade-offs | Doc-stated or our guidance |
|---|---|---|---|
| Release coordination with Cloud releases | Track 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 approval | PR 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 strategy | Manual 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.
| Item | Date | Action |
|---|---|---|
| HostEvent v1 framework Deprecated | Deprecated 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.cl | Set 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 parameterization | Deprecated 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, doNotTrackPreRenderSize | Deprecated SDK 1.52.0 | Use 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.cl | Move to ABAC via RLS with custom variables and variable_values. docs |
every_n_minutes schedules | Deprecated 26.8 | Use hourly or longer. The deprecation notice and the REST changelog differ on timing; see the Exports section. docs |
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.
Go-live checklist
What to check before launch, and what to do on the day. Your progress is saved in this browser.
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
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.
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).
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.
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+docsCloud 26.11.0.cl
Upcoming. Starts a 50 requests per second per Org REST limit on instances with Orgs. See Running it in production.
docsLatest SDK with a changelog entry is 1.53.0 (October 2026); the latest Cloud release is 26.10.0.cl. docs
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
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.
| Feature | Status | Version | Section | Source |
|---|
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.
| When | Feature | Status | Version | What it does |
|---|---|---|---|---|
| Sep 2025 | Persona-based left nav and home (discoveryExperience) | Early access | SDK 1.41.0 | New 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 2025 | Code-based custom actions | GA | SDK 1.43.0 · TS 10.14.0.cl | Define custom actions in code with CustomActionTarget and CustomActionsPosition.docs |
| Jan 2026 | API interception (interceptUrls, EmbedEvent.ApiIntercept) | New | SDK 1.43.0 · TS 10.15.0.cl | Let the host allow, modify or block API calls and search execution. The changelog lists it under 1.44.x.docs |
| Feb 2026 | Spotter 3 in embed (updatedSpotterChatPrompt, auto mode, error enums) | Early access | SDK 1.45.0 · TS 26.2.0.cl | What'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 2026 | enableLinkOverridesV2 | New | SDK 1.46.0 · TS 26.2.0.cl | Recommended over linkOverride for overriding ThoughtSpot URL navigation. Links route through EmbedEvent.DialogOpen. |
| Mar 2026 | Host Events V2 (useHostEventsV2, getCurrentContext()) | Beta | SDK 1.45.2 · TS 26.3.0.cl | Context-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 2026 | Spotter chat-history sidebar (spotterSidebarConfig) and spotterChatConfig | New | SDK 1.47.0 · TS 26.4.0.cl | Title, 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 2026 | Centralized Liveboard filter modal (isCentralizedLiveboardFilterUXEnabled) | Early access | SDK 1.47.0 · TS 26.4.0.cl | One 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 2026 | personalizedViewId | New | SDK 1.47.0 · TS 26.4.0.cl | Embed a saved Personalized View of a Liveboard, together with liveboardId. The docs do not label this flag Beta or Early Access. |
| Apr 2026 | Collections REST APIs (/collections/create, search, {id}/update, delete) | Beta | TS 26.4.0.cl | Create, search, update and delete Collections. |
| May 2026 | EmbedEvent.Subscribed | New | SDK 1.48.0 · TS 26.4.0.cl | Fires 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 2026 | Spotter agent REST: /ai/agent/conversation/{id}/send and /send/stream | New | TS 26.5.0.cl | Replace 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 2026 | Continuous Liveboard PDF (isContinuousLiveboardPDFEnabled) | Beta | SDK 1.48.x · TS 26.5.0.cl | Renders 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 2026 | visualOverrides and enableLiveboardDataCache | New | SDK 1.49.0 · TS 26.6.0.cl | Init-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 2026 | SpotterViz (spotterViz, SpotterVizConfig) | Early access | SDK 1.50.0 · TS 26.7.0.cl | Brand and customize the conversational Liveboard builder panel. Disabled by default.docs |
| Jul 2026 | HomePage.Focused (V4 home) in discoveryExperience.homePage | Early access | SDK 1.50.0 · TS 26.7.0.cl | A focused home page for full-app embed. The docs mark it Early Access and disabled by default.docs |
| Aug 2026 | Spotter Analysts | Early access | SDK 1.51.0 · TS 26.8.0.cl | Analyst agents in the sidebar. Disabled by default in embed.docs |
| Aug 2026 | Liveboard defaults flipped on (hide irrelevant filters, compact header, masterpieces, enhanced filter interactivity, cover and filter page in PDF) | GA | SDK 1.51.0 · TS 26.8.0.cl | These were Early Access and are now on by default on embedded instances. The compact header has breaking UI changes. |
| Sep 2026 | preRenderConfig | New | SDK 1.52.0 | One 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 2026 | updatedSpotterExperience and spotterShareConversationConfig | New | SDK 1.52.0 · TS 26.9.0.cl | Opt-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 2026 | HomeLeftNavItem.Collections | New | SDK 1.52.0 · TS 26.9.0.cl | Show Collections in the full-app left nav. Shown when Collections are enabled on the cluster.docs |
| Oct 2026 | spotterAnalystConfig.analystId and Spotter conversation pinning (spotterChatPinConfig) | New | SDK 1.53.0 · TS 26.10.0.cl | Pin a SpotterEmbed to one governed Analyst; let users pin conversations at the top of the history list.docs |
| Oct 2026 | Contextual filtering in Liveboards (isScopedLiveboardFilteringEnabled, applicability, HostEvent.GetGroups, HostEvent.OpenParameter) | Early access | SDK 1.53.0 · TS 26.10.0.cl | Three 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 2026 | Column security rules on Liveboards | New | TS 26.10.0.cl | Liveboards 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 |
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.
| Item | Deadline | Replacement / action |
|---|---|---|
| HostEvent v1 framework | Deprecated 26.10.0.cl (Oct 2026); end of support Mar 2027 | Enable useHostEventsV2 in non-prod and test. V2 validates payloads and routes by context. docs migration |
Custom action UI workflow and /customization/custom-actions REST endpoints | Announced: deprecated from 26.12.0.cl (Dec 2026); removal June 2027 | Code-based custom actions. Existing API integrations keep working until removal. docs |
POST /auth/token/object | Announced: deprecated from 26.12.0.cl (Dec 2026); removal June 2027 | Use /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/parameterize | Deprecated 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 privilege | Renamed 26.3.0.cl; removal 26.9.0.cl (Sep 2026). Date has passed; the docs do not confirm removal, so check your cluster | Use CAN_USE_SPOTTER. Roles on RBAC instances need to be recreated with the new privilege name. docs |
| V1 and V2 full-app nav and home | Deprecated Feb 2026; V3 default from 26.8.0.cl; removal Aug 2026. Date has passed; the docs do not confirm removal, so check your cluster | Test with the V3 experience. docs |
| Legacy Liveboard PNG export workflow | Deprecated 26.4.0.cl; removal Aug 2026. Date has passed; the docs do not confirm removal, so check your cluster | New high-resolution PNG flow in /report/liveboard. docs |
Answer classic Data panel; dataPanelV2 | Deprecated 26.4.0.cl; classic removal Aug 2026. Date has passed; the docs do not confirm removal, so check your cluster | The new panel is always on, and dataPanelV2 is deprecated (SDK 1.46.x). docs |
| Liveboard and Answer "discoverable" settings | Deprecated 26.2.0.cl (Feb 2026); removal Aug 2026. Date has passed; the docs do not confirm removal, so check your cluster | Share explicitly with users and groups before upgrading. docs |
Top-level preRenderId, preRenderContainer, doNotTrackPreRenderSize | Deprecated SDK 1.52.0 | preRenderConfig. docs |
Top-level enablePastConversationsSidebar | Deprecated SDK 1.47.0 · TS 26.4.0.cl | spotterSidebarConfig.enablePastConversationsSidebar. docs |
defaultHeight | Deprecated SDK 1.44.2 | minimumHeight. docs |
isLiveboardStylingAndGrouping | Deprecated SDK 1.45.0 | isLiveboardMasterpiecesEnabled (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.cl | The ...Personalized... spellings. HostEvent.UpdatePersonalizedView is itself tagged deprecated at SDK 1.53.0 in favor of HostEvent.SelectPersonalizedView. docs |
linkOverride | Superseded SDK 1.46.0 | enableLinkOverridesV2 is recommended. docs |
/ai/agent/{id}/converse and /ai/agent/converse/sse | Deprecated 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_context | Breaking at 26.5.0.cl (the context options are removed) | metadata_context with AUTO_MODE or DATA_SOURCE. docs |
HostEvent.GetExportRequestForCurrentPinboard payload shape | Breaking SDK 1.48.x | Read 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 stated | variable_values with ABAC via RLS variables. docs |
minute as a schedule frequency (/schedules/create, /update) | Rejected from 26.8.0.cl; existing schedules become hourly | Use hourly or longer. Contact ThoughtSpot Support if you need sub-hourly. docs |
SpotterCode MCP unauthenticated use of /mcp | Authentication required from Aug 2026 | Sign 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 object | Deprecated 10.4.0.cl; removal date not stated | auth/token/custom. docs |
Sage / SageEmbed, Worksheets, customCssUrl | Already removed or past end of support (Sage Dec 2025, Worksheets Sep 2025, customCssUrl Jul 2024) | Spotter, Models, customizations.style.customCSSUrl. docs |
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.
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
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.
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
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.
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
| Topic | What the docs say | Our guidance |
|---|---|---|
| Audit log fetch | Needs 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 limit | On 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. docs | The 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 limits | Per user, per minute: 10 conversation creations, 30 messages per conversation. The docs advise retry logic. docs | Queue and back off on your side. |
| Embed errors | EmbedEvent.Error carries an errorType (such as API), and auth problems also surface as AuthStatus.FAILURE, EmbedEvent.AuthFailure, AuthExpire and NoCookieAccess. docs | Send 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/bannerreturnsunder_maintenanceandbanner_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=trueplus thesecurity-keyheader, 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.
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.