Skip to main content

Advanced Analytics

Overview​

@shared/advanced-analytics embeds ThoughtSpot analytics into rhapsody using Trusted Authentication. The package provides a single provider (AdvancedAnalyticsProvider), a high-level AnalyticsLiveboard component for pages, a StandaloneLiveboard for Liveboards shown inside the analytics modal, a custom actions API with a shared analytics modal, and the useThoughtSpotAuth hook. Pages only need a Liveboard's ThoughtSpot obj_id (liveboardObjId) to get a fully managed embed with loading skeleton, error state and Sentry reporting. Every AnalyticsLiveboard comes with saved views, stored under the viewKey the page gives it. The filter options (defaultFilters, syncFiltersWithHostUrl, onFilterStateChange) belong to StandaloneLiveboard: AnalyticsLiveboard uses them internally to drive its views.

@app/advanced-analytics is the first consumer: three flag-gated routes that exercise the embed end to end. See Host app. To add the package to a new app, follow Building a new app.


Quick start​

1. Wrap routes with the provider​

import { AdvancedAnalyticsProvider } from '@shared/advanced-analytics'

<AdvancedAnalyticsProvider>
<Switch>
<Route path="/my-analytics" component={MyPage} />
</Switch>
</AdvancedAnalyticsProvider>

It is the only provider the package needs: ThoughtSpot auth plus the shared analytics modal.

2. Drop in the Liveboard​

import {
AnalyticsLiveboard,
GENERAL_VISIBLE_ACTIONS,
} from '@shared/advanced-analytics'

export function MyPage() {
return (
<AnalyticsLiveboard
viewKey="<melody-view-key>"
liveboardObjId="<liveboard-obj-id>"
visibleActions={GENERAL_VISIBLE_ACTIONS}
/>
)
}

That's it. viewKey is the Melody view the page's saved views are stored under. obj_id resolution, auth lifecycle, loading skeleton, error state, locale resolution, Sentry reporting and the "Drill Down" action are all handled internally.

Two components​

ComponentUse it forSaved viewsFilter options
AnalyticsLiveboardThe Liveboard of a page. viewKey is requiredAlways: picker, Save / Reset, sharing, default viewNot accepted: defaultFilters, syncFiltersWithHostUrl, filtersUrlParam, onFilterStateChange and embedSize are owned by the views layer
StandaloneLiveboardA Liveboard shown in the analytics modal (what createOpenLiveboardAction renders)NoneAll of them: defaultFilters, syncFiltersWithHostUrl, filtersUrlParam, onFilterStateChange, embedSize

AnalyticsLiveboard is StandaloneLiveboard plus the views layer, so everything below that is about the embed itself (props, obj_id resolution, errors, custom actions, filter sync) applies to both. The props table lists the union; the rows that AnalyticsLiveboard does not accept are marked.


Building a new app​

A step-by-step guide for a new consumer of the package. The examples use a hypothetical @app/my-analytics app with a pipeline page and an accounts page.

What you need​

NeedProvided by
ThoughtSpot auth, the shared analytics modalAdvancedAnalyticsProvider (this package), added once
React Router (useHistory, used by ctx.navigate) and a TanStack QueryClientThe rhapsody shell — every app route already renders inside them
window.ENV.THOUGHTSPOT_URLThe server env (see Environment variable)

Add the package to the app's package.json:

"dependencies": {
"@shared/advanced-analytics": "workspace:*"
}

Suggested structure​

apps/my-analytics/src/
├── liveboards.ts ← obj_ids (and default filters) of the app's Liveboards
├── customActions.tsx ← CUSTOM_ACTIONS: the actions the app's pages pick from
└── pages/
├── MyAnalytics.tsx ← AdvancedAnalyticsProvider + routes
├── PipelinePage.tsx
├── AccountsPage.tsx
└── messages.ts

1. Add the provider once​

AdvancedAnalyticsProvider is the only provider an app needs. It initializes ThoughtSpot auth and renders one shared analytics modal for everything below it. Place it around the routes, not inside each page:

// pages/MyAnalytics.tsx
import { Route, Switch } from 'react-router-dom'

import { AdvancedAnalyticsProvider } from '@shared/advanced-analytics'

import { PipelinePage } from './PipelinePage.tsx'
import { AccountsPage } from './AccountsPage.tsx'

export const MyAnalytics = () => (
<AdvancedAnalyticsProvider>
<Switch>
<Route exact path="/my-analytics/pipeline" component={PipelinePage} />
<Route exact path="/my-analytics/accounts" component={AccountsPage} />
</Switch>
</AdvancedAnalyticsProvider>
)

2. Declare the Liveboards​

Reference Liveboards by obj_id only (see Referencing Liveboards by obj_id). Default filters that more than one place needs — the page and the actions that navigate to it — live next to them:

// liveboards.ts
import type { LiveboardFilter } from '@shared/advanced-analytics'

export const LIVEBOARDS = {
pipeline: { liveboardObjId: 'PipelineOverview-1234abcd' },
accounts: { liveboardObjId: 'AccountDetail-5678efgh' },
} as const

export const ACCOUNTS_DEFAULT_FILTERS: LiveboardFilter[] = [
{ column: 'Industry', oper: 'IN', values: ['CPG'] },
]

3. Declare the app's custom actions​

Keep one dictionary per app. Each entry is a LiveboardCustomAction; satisfies checks every entry while keeping the keys typed. Use a .tsx file when an action renders components. The three ways to build one, from least to most control:

// customActions.tsx
import {
RecordsTable,
toRuntimeFilters,
toLiveboardFilters,
defineCustomAction,
resolveRecordsQuery,
createNavigateAction,
mergeLiveboardFilters,
recordsDrilldownAction,
createOpenLiveboardAction,
type DrilldownClick,
type LiveboardTarget,
type LiveboardCustomAction,
} from '@shared/advanced-analytics'

import { LIVEBOARDS, ACCOUNTS_DEFAULT_FILTERS } from './liveboards.ts'
import { messages } from './pages/messages.ts'
import { AccountSummary } from './components/AccountSummary.tsx'

/** Clicked column → column of the accounts Liveboard. Only these are forwarded. */
const ACCOUNT_COLUMNS = { 'Account Name': 'Account' }

/**
* What to open for each pipeline visualization. Targets come from this list,
* never from clicked values, so a click can only open what is declared here.
*/
const ACCOUNT_TARGETS: Record<string, (click: DrilldownClick) => LiveboardTarget> = {
'viz-pipeline-by-account': (click) => ({
title: messages.accountDetail,
liveboardObjId: LIVEBOARDS.accounts.liveboardObjId,
runtimeFilters: toRuntimeFilters(click, { columns: ACCOUNT_COLUMNS }),
}),
'viz-deals-by-stage': (click) => ({
title: messages.stageDeals,
liveboardObjId: LIVEBOARDS.accounts.liveboardObjId,
vizId: 'viz-deals-table',
defaultFilters: toLiveboardFilters(click, { columns: { Stage: 'Deal Stage' } }),
}),
}

export const CUSTOM_ACTIONS = {
/** 1. A built-in action, as is. */
records: recordsDrilldownAction,

/** 2a. Factory: open a Liveboard or viz in the modal, picked per click. */
openAccount: createOpenLiveboardAction({
id: 'open-account',
label: () => messages.openAccountLabel,
scope: { metadataIds: { vizIds: Object.keys(ACCOUNT_TARGETS) } },
resolveTarget: (click) => ACCOUNT_TARGETS[click.vizId ?? '']?.(click) ?? null,
}),

/** 2b. Factory: go to another page, which opens its Liveboard filtered. */
goToAccounts: createNavigateAction({
id: 'go-to-accounts',
label: () => messages.goToAccountsLabel,
resolveRoute: (click) => ({
pathname: '/my-analytics/accounts',
liveboardFilters: mergeLiveboardFilters(
ACCOUNTS_DEFAULT_FILTERS,
toLiveboardFilters(click, { columns: ACCOUNT_COLUMNS })
),
}),
}),

/** 3a. Custom action: render one of the app's components in the modal. */
accountSummary: defineCustomAction({
id: 'account-summary',
label: () => messages.accountSummaryLabel,
run: (click, { modal, refuse }) => {
const account = click.attributes.find(({ name }) => name === 'Account Name')
if (typeof account?.value !== 'string') {
refuse('NO_ACCOUNT', click.vizId)
return
}

modal.open({
title: account.value,
content: <AccountSummary accountName={account.value} />,
})
},
}),

/** 3b. Custom action reusing the records pipeline with its own title. */
dealRecords: defineCustomAction({
id: 'deal-records',
label: () => messages.dealRecordsLabel,
run: async (click, context) => {
const query = await resolveRecordsQuery(click, context)
if (!query) return

context.modal.open({
title: messages.dealRecordsTitle,
content: <RecordsTable query={query} />,
})
},
}),
} as const satisfies Record<string, LiveboardCustomAction>

Labels are getters on the app's messages, like any other copy:

// pages/messages.ts
export const messages = {
get openAccountLabel() {
return intl.formatMessage({
defaultMessage: 'Open account',
description: 'Chart context menu entry that opens the account Liveboard',
})
},
// …
}

4. Pick the actions per page​

Each AnalyticsLiveboard offers exactly the actions it receives. Declare the list outside the component, so it keeps its identity across renders:

// pages/PipelinePage.tsx
import {
AnalyticsLiveboard,
GENERAL_VISIBLE_ACTIONS,
} from '@shared/advanced-analytics'

import { LIVEBOARDS } from '../liveboards.ts'
import { CUSTOM_ACTIONS } from '../customActions.tsx'

const PIPELINE_ACTIONS = [
CUSTOM_ACTIONS.openAccount,
CUSTOM_ACTIONS.goToAccounts,
CUSTOM_ACTIONS.accountSummary,
]

export function PipelinePage() {
return (
<AnalyticsLiveboard
viewKey={LIVEBOARDS.pipeline.viewKey}
liveboardObjId={LIVEBOARDS.pipeline.liveboardObjId}
visibleActions={GENERAL_VISIBLE_ACTIONS}
customActions={PIPELINE_ACTIONS}
/>
)
}
// pages/AccountsPage.tsx
const ACCOUNTS_ACTIONS = [CUSTOM_ACTIONS.records, CUSTOM_ACTIONS.dealRecords]

export function AccountsPage() {
return (
<AnalyticsLiveboard
viewKey={LIVEBOARDS.accounts.viewKey}
standardViews={ACCOUNTS_STANDARD_VIEWS}
liveboardObjId={LIVEBOARDS.accounts.liveboardObjId}
visibleActions={GENERAL_VISIBLE_ACTIONS}
customActions={ACCOUNTS_ACTIONS}
/>
)
}

AccountsPage opens with the filters of its first view from Salesloft (ACCOUNTS_STANDARD_VIEWS, a list of { name, filters: ACCOUNTS_DEFAULT_FILTERS }). Filters that goToAccounts puts in the URL are not applied by a page with saved views: see Known limitations. Omit customActions to offer only the two "Drill Down" actions, recordsDrilldownAction and kpiDrillAction (DEFAULT_CUSTOM_ACTIONS), or pass [] to offer none.

5. Open the modal from your own UI​

useAnalyticsModal() works anywhere below AdvancedAnalyticsProvider, not only inside custom actions:

import {
StandaloneLiveboard,
useAnalyticsModal,
} from '@shared/advanced-analytics'

export function ForecastButton() {
const modal = useAnalyticsModal()

return (
<Button
onClick={() =>
modal.open({
title: messages.forecast,
content: (
<StandaloneLiveboard
embedSize="modal"
liveboardObjId={LIVEBOARDS.forecast.liveboardObjId}
/>
),
})
}
>
{messages.openForecast}
</Button>
)
}

Checklist for custom actions​

  • Unique id per Liveboard. A repeated id is reported, and only the first action with it runs.
  • label is a function (() => messages.x), so it follows the user's locale.
  • Refuse instead of failing silently. Call refuse(reason, detail) when a click can't be handled. Errors thrown by run are reported for you and never reach the Liveboard.
  • Targets and routes come from code. Pick liveboardObjIds and routes from lists you declare (LIVEBOARDS, a map by vizId), never from clicked values. ctx.navigate refuses anything that is not an in-app path.
  • Forward only columns the target has. Pass columns to toRuntimeFilters / toLiveboardFilters when the target names its columns differently or shares only some of them.
  • Keep the destination's defaults when navigating. Filters in the URL replace a board's defaultFilters, so merge them back with mergeLiveboardFilters(defaults, clicked).
  • Use scope (metadataIds.vizIds, dataModelIds…) so ThoughtSpot only shows the action where it applies.

Architecture​

Browser (rhapsody)
│
│ GET /v2/thoughtspot/embed_token
│ (Bearer: Salesloft user token)
▼
analytics-api
│ Resolves user + tenant from server-side context only
│ POST /api/rest/2.0/auth/token/custom → ThoughtSpot
▼
ThoughtSpot
│ Short-lived token (expires_in seconds)
▼
analytics-api → rhapsody
│
│ Visual Embed SDK initializes iframe
▼
ThoughtSpot (embedded iframe)
│ Authorized query → Cube (tenant-isolated via ts_var RLS)
▼
Embedded result in rhapsody

Key security properties:

  • The ThoughtSpot Trusted Auth secret never leaves analytics-api
  • User identity and tenant are resolved server-side only — no identity data is trusted from the browser
  • Row-level security (ABAC via ts_var()) enforces tenant isolation at the data layer
  • Tokens are short-lived (expires_in seconds); the SDK refreshes them automatically via getAuthToken

Package structure​

shared/advanced-analytics/
├── package.json
├── docs/
│ ├── load-performance.md ← load analysis: strategies tried, evidence, what was adopted and why
│ └── saved-views-backend-changes.md ← changes requested to Melody's saved_list_views
└── src/
├── index.ts ← public API
├── types.ts
├── constants.ts
├── services/ ← one folder per service: [name].ts, [name].spec.ts, types.ts, constants.ts
│ ├── thoughtspotAuthService/ ← fetchThoughtSpotToken: Trusted Auth token from analytics-api
│ └── liveboardService/ ← fetchLiveboardId (GUID alone) and fetchLiveboard (with details): obj_id via ThoughtSpot metadata/search
├── context/
│ ├── AdvancedAnalyticsProvider/ ← the one provider apps add: auth + analytics modal
│ └── ThoughtSpotAuthContext/
│ ├── ThoughtSpotAuthContext.tsx
│ ├── constants.ts ← THOUGHTSPOT_AUTH_STATUS
│ └── types.ts
├── hooks/ ← one folder per hook: [name].ts, [name].spec.ts(x)
│ ├── useLiveboardViewBridge/ ← saved views store ⇄ Liveboard: baseline, isModified, Reset
│ ├── useHostParamsUrlSync/ ← date range and user / group / account selections: saved views store ⇄ URL
│ ├── useDateRangeFilter/ ← the open view's range as a DateFilterConfig, waiting for the fiscal calendar
│ ├── useDateRuntimeFilters/ ← the range as a runtime filter: on open by prop, later by UpdateRuntimeFilters
│ ├── useThoughtSpotAuth/
│ ├── useLiveboardFilterUrlSync/ ← filter ⇄ URL sync (see below)
│ ├── useLiveboardQuery/ ← GUID and details queries, waitForDetails, loadDetails, localStorage first answer
│ ├── usePrefetchLiveboard/ ← starts the Liveboard above the saved views gate
│ ├── useLiveboardStatus/ ← embed lifecycle (loading → ready | error), reset per Liveboard
│ ├── useLiveboardCustomActions/ ← custom actions → SDK definitions + click dispatch
│ ├── useAnalyticsModal/ ← open/back/close the shared analytics modal
│ └── useEventCallback/ ← stable callback identity helper
├── customActions/ ← custom actions API and the built-in actions
│ ├── types.ts ← LiveboardCustomAction, CustomActionContext, AppRoute
│ ├── defineCustomAction.ts
│ ├── defaultCustomActions.ts ← DEFAULT_CUSTOM_ACTIONS
│ ├── needsLiveboardDetails.ts ← whether the embed config depends on the Liveboard's details
│ ├── recordsDrilldownAction.tsx ← "Drill Down" + resolveRecordsQuery
│ ├── kpiDrillAction.tsx ← "Drill Down" + resolveKpiDrillQuery
│ ├── createOpenLiveboardAction.tsx
│ └── createNavigateAction.ts
├── utils/
│ ├── drilldown/
│ │ ├── drilldown.ts ← payload → DrilldownClick; click → Cube members → records query
│ │ ├── constants.ts ← model → view map, failure reasons
│ │ └── types.ts ← ThoughtSpot custom action payload, DrilldownClick, Cube meta types
│ ├── liveboardCustomizations/ ← the look every page Liveboard shares (Ask button, CSS rules, theme variables) and mergeCustomizations
│ ├── savedViews/ ← buildStandardViews, keepUserViewsInTheirSection, setFiltersParam, typed @shared/saved-views
│ ├── tableUtils/ ← Cube annotation → RecordsTable columns: link, id and drill cells, NewTabLink
│ ├── clickFilters/ ← click → runtime filters / Liveboard filters
│ ├── dateFilter/ ← DEFAULT_DATE_RANGE, resolveDateRange (epoch seconds), toDateRuntimeFilters
│ ├── hostParams/ ← host params ⇄ URL: read, write, signature, apply to a view
│ ├── liveboardCache/ ← the resolved Liveboard in localStorage, per cluster and user
│ ├── navigation/ ← in-app route validation for ctx.navigate
│ ├── embedError/
│ │ └── embedError.ts ← toEmbedErrorReport: allowlisted error view
│ └── filterSync/
│ ├── filterSync.ts ← URL (de)serialization, validation, SDK payload mappers
│ ├── constants.ts ← operators, limits, applicability levels
│ └── types.ts ← hook options/result, UpdateFilters payload
└── components/
├── AnalyticsModal/ ← AnalyticsModalProvider: one modal, stacked frames, error boundary
├── LiveboardToolbar/ ← the row above every page Liveboard that holds the host's actions (Box); only the views picker for now
├── RecordsTable/ ← paginated Cube records, modal content of "Drill Down"
├── ClickedRecords/ ← the clicked point's attributes card above its records, when the measure declares no summary
├── SummaryDrilldown/ ← meta.drilldownSummary cards above the records of "Drill Down"
├── SavedViewsControls/ ← the views picker + Save / Reset, placed in the toolbar
├── DateRangeFilter/ ← the shared DateFilter, backed by the saved views store
├── AnalyticsLiveboard/ ← the page Liveboard: saved views provider, toolbar, view bridge, around StandaloneLiveboard
│ ├── AnalyticsLiveboard.tsx
│ ├── AnalyticsLiveboard.spec.tsx
│ ├── constants.ts ← SAVED_VIEWS_FRAME_PARAMS
│ ├── messages.tsx
│ └── types.ts
└── StandaloneLiveboard/ ← the embed without views (modals); what AnalyticsLiveboard wraps
├── StandaloneLiveboard.tsx
├── StandaloneLiveboard.spec.tsx
├── components.tsx ← DefaultLoadingSkeleton, DefaultErrorState (`reason`: error | maintenance)
├── components.spec.tsx
├── constants.ts ← SENTRY_TAGS, ERROR_STATE_REASON
├── styles.tsx
├── messages.tsx ← loading, error and maintenance copy (translatable)
└── types.ts

The app that consumes it:

apps/advanced-analytics/
├── entry.ts ← routes, nav items, shortcuts, feature flag
└── src/
├── bridge-loaders/routesBridge.tsx
├── liveboards.ts ← LIVEBOARDS: per Liveboard, everything its tab needs (view key, date column, Display By); PAGES: the tabs of each page
├── types.ts ← LiveboardConfig, PageConfig
├── utils/displayByTables/ ← the Display By tables a Liveboard can offer, by id
├── components/
│ ├── TabbedLiveboardPage/ ← route, tab in the path, header and Spotter panel of a page
│ │ └── components/PageHeader/
│ ├── LiveboardTab/ ← the body of a tab: Display By tables or the Liveboard whole
│ │ └── components/ ← DisplayByTab, DisplayByLiveboard, LiveboardEmbed
│ └── DisplayByPicker/
├── hooks/ ← useDisplayBy, useSpotterPanel
└── pages/
├── AdvancedAnalytics/ ← flag guard + auth provider + <Switch>
├── ProspectingAccounts/ ← ProspectingResults: a title and PAGES.prospectingResults
└── TeamProductivity/ ← a title and PAGES.teamProductivity

Public API​

Everything exported from @shared/advanced-analytics:

ExportKindDescription
AnalyticsLiveboardComponentThe Liveboard of a page: embed with auth lifecycle, skeleton, error UI, Sentry and saved views (viewKey)
StandaloneLiveboardComponentThe same embed without saved views, for Liveboards shown in the analytics modal
AdvancedAnalyticsProviderComponentThe one provider apps add: ThoughtSpot auth (SDK init, token rotation) and the shared analytics modal
useThoughtSpotAuthHookReturns { status, isReady, error, locale } for custom auth handling
GENERAL_VISIBLE_ACTIONSConstantCurated read-only action preset for visibleActions
THOUGHTSPOT_HOSTConstantThoughtSpot host URL from window.ENV.THOUGHTSPOT_URL
DEFAULT_HOST_URL_FILTERS_PARAMConstantDefault query param (tsFilters) used by syncFiltersWithHostUrl
ActionRe-exportThoughtSpot SDK Action enum
defineCustomActionFunctionTypes a LiveboardCustomAction declaration
recordsDrilldownActionAction"Drill Down": records behind the clicked point in the modal
kpiDrillActionAction"Drill Down": records behind a KPI visualization in the modal
DEFAULT_CUSTOM_ACTIONSConstantActions of a Liveboard without customActions ([recordsDrilldownAction, kpiDrillAction])
createOpenLiveboardActionFunctionAction that opens a Liveboard or viz, picked per click, in the modal
createNavigateActionFunctionAction that navigates to an in-app route, optionally with Liveboard filters
resolveRecordsQueryFunctionClick → Cube records query (or refusal), for custom records actions
resolveKpiDrillQueryFunctionKPI click → Cube query from meta.drilldown (or refusal)
toRuntimeFiltersFunctionClick → RuntimeFilter[] for another Liveboard or viz
toLiveboardFiltersFunctionClick → Liveboard filters for ctx.navigate or a target's defaultFilters
mergeLiveboardFiltersFunctionBase filters with overrides added, replacing filters on the same column
useAnalyticsModalHook{ open, back, close } of the shared analytics modal
RecordsTableComponentPaginated table of a Cube query's rows
AnalyticsLiveboardPropsTypeProps accepted by AnalyticsLiveboard
StandaloneLiveboardPropsTypeProps accepted by StandaloneLiveboard
StandardViewDefinitionTypeOne view from Salesloft: { name, filters }
LiveboardFilterTypeOne Liveboard filter, as accepted by defaultFilters and stored in the URL
EmbedSizeType'page' \| 'modal'
FilterStateChange, FilterStateSource, PersistedFilterStateTypesWhat onFilterStateChange reports: { state, source }, the source (initial, navigation, user), and the { liveboardFilters } state
LiveboardCustomAction, CustomActionContext, CustomActionScope, AppRoute, DrilldownClick, ClickedColumn, AnalyticsModalApi, AnalyticsModalFrame, LiveboardTarget, OpenLiveboardActionOptions, NavigateActionOptions, ClickFilterOptionsTypesCustom actions API
ThoughtSpotTokenTypeToken response shape from analytics-api

Host app: @app/advanced-analytics​

The package is consumed today by a single app whose only purpose is to exercise the embed in a real rhapsody shell. AdvancedAnalytics.tsx wraps its routes in AdvancedAnalyticsProvider, and each route is a page that renders AnalyticsLiveboard against its own Liveboards.

Routes​

Registered in apps/advanced-analytics/entry.ts via registerRoutes, all resolved through one bridge loader (src/bridge-loaders/routesBridge.tsx → AdvancedAnalytics).

RoutePageNotes
/advanced-analytics/prospecting-results/:tabProspectingResultsOne tab per Liveboard: accounts, team-cadences, calls, emails, meetings
/advanced-analytics/team-productivity/:tabTeamProductivityOne tab per Liveboard: team-performance, process, call-sentiments, call-dispositions

Everything a tab needs is declared in LIVEBOARDS (src/liveboards.ts), next to its obj_id and viewKey: its tabPath, the tabLabelKey of its tab text (also the name Spotter is told for the Liveboard) and, optionally, its displayBy (the query parameter and the table ids from DISPLAY_BY_TABLES). PAGES lists the tabs of each page. A page file only gives TabbedLiveboardPage its title and its PAGES entry.

The selected tab is part of the path, as on Analytics Hub (/analytics-hub/prospecting/calls), so a copied URL opens the same tab with its filters (tsFilters) and Display By parameter. Each tab path is registered in entry.ts, because registerRoutes takes exact paths; the page's <Route> uses :tab?.

URLWhat happens
/advanced-analytics/prospecting-resultsRedirects to /accounts, keeping the query string
/advanced-analytics/team-productivityRedirects to /team-performance, keeping the query string
An unknown tabSame redirect as no tab
Clicking a tabhistory.push to the tab's path without the query string: filters and Display By belong to the Liveboard being left, as with Analytics Hub's tab links

All pages pass GENERAL_VISIBLE_ACTIONS and the viewKey of their Liveboard (LIVEBOARDS.<name>.viewKey in src/liveboards.ts), so they all have saved views, and each one builds its own custom actions (DEFAULT_CUSTOM_ACTIONS plus its own). See Custom actions.

Feature flag​

Everything is gated behind advanced_analytics:

  • entry.ts — each registerGlobalNavMenuItem declares shouldShow: ({ hasFeatureFlag }) => hasFeatureFlag('advanced_analytics'), so the nav entries and their shortcuts stay hidden without it.
  • AdvancedAnalytics.tsx — re-checks with hasFeatureFlag('advanced_analytics') from @rhapsody/feature-flags and returns <Redirect to="/" /> when absent, so direct URL access is blocked too.

Page shape​

Every page is a few lines — the package handles auth, loading, and errors:

import {
AnalyticsLiveboard,
GENERAL_VISIBLE_ACTIONS,
DEFAULT_CUSTOM_ACTIONS,
} from '@shared/advanced-analytics'

import { LIVEBOARDS } from '../../liveboards.ts'

export function MyPage() {
return (
<AnalyticsLiveboard
viewKey={LIVEBOARDS.prospectingAccounts.viewKey}
liveboardObjId={LIVEBOARDS.prospectingAccounts.liveboardObjId}
visibleActions={GENERAL_VISIBLE_ACTIONS}
customActions={DEFAULT_CUSTOM_ACTIONS}
/>
)
}

AnalyticsLiveboard​

The single component for embedding Liveboards. Wraps the SDK's LiveboardEmbed directly and manages the full lifecycle.

Auth lifecycle​

Auth statusRenders
idle / loadingPulsing dashboard skeleton (DefaultLoadingSkeleton)
errorDefaultErrorState (Starlight EmptyState + DisconnectedImage) + captureException to Sentry
readyLiveboardEmbed with skeleton overlaid until onLiveboardRendered fires

Embed lifecycle (after auth is ready)​

Internally tracked by useLiveboardStatus, reset whenever the resolved Liveboard changes:

StatusMeaningRenders
loadingMounted, no render confirmed yetLiveboardEmbed + skeleton overlay
readyonLiveboardRendered firedLiveboardEmbed, no overlay
errorAn error arrived before any renderDefaultErrorState

Post-render errors are safe: an error that arrives after a successful render is reported to Sentry (with hasRendered: true) but does not replace the board with an error state. This is deliberate — the SDK reports single-visualization failures through the same onError channel.

Locale resolution​

The locale prop takes priority when provided. Otherwise the locale resolved by AdvancedAnalyticsProvider (via @shared/user-locale) is used automatically.

Props​

PropTypeRequiredDescription
liveboardObjIdstring✅ThoughtSpot obj_id of the Liveboard, stable across environments. Resolved to the GUID first. See Referencing Liveboards by obj_id
localestringBCP 47 locale. Falls back to auth context locale
activeTabIdstringGUID of the tab the Liveboard opens on
runtimeFiltersRuntimeFilter[]UI context filters at query time (not authorization — ts_var() RLS is the isolation layer). Owned by the host app: passed to the embed as-is and never written to the URL
syncFiltersWithHostUrlbooleanStandaloneLiveboard only. Keeps the Liveboard filter state in sync with the host URL. See Filter sync with the host URL. Defaults to false
filtersUrlParamstringStandaloneLiveboard only. Query param that holds the filter state. Give each synced board its own when several share a page. Defaults to DEFAULT_HOST_URL_FILTERS_PARAM
defaultFiltersLiveboardFilter[]StandaloneLiveboard only. Liveboard filters (editable chips) the board opens with. See Default filters
visibleActionsAction[]Allowlist of actions. Mutually exclusive with hiddenActions
loadingFallbackReactNodeReplaces the default skeleton while auth, the obj_id resolution or the embed is loading
errorFallbackReactNodeReplaces DefaultErrorState on auth failure, or a pre-render embed error
onLoad() => voidCalled when the embed iframe loads
onLiveboardRendered() => voidCalled when the Liveboard content renders. URL or default filters may still be pending at that point; they are applied once the embed subscribes to UpdateFilters
onError(error: unknown) => voidCalled on every embed error. Sentry reporting always fires independently
onFilterChanged(payload: MessagePayload) => voidCalled on every filter change with the raw SDK payload. Runs alongside the URL sync, never instead of it
onFilterStateChange(change: FilterStateChange) => voidStandaloneLiveboard only. Called with each filter state read from the board and where it came from (initial, navigation or user). Only runs with syncFiltersWithHostUrl. See Reported filter state
dateColumnstringAnalyticsLiveboard only: name of the Liveboard's date column. Shows the Analytics Hub date picker in the toolbar and keeps the Liveboard to the range with a runtime filter on it. See Date range filter
viewKeystring✅ (AnalyticsLiveboard)The Melody view the page's saved views are stored under. Not accepted by StandaloneLiveboard
standardViews{ name, filters }[]AnalyticsLiveboard only: the "Views from Salesloft"; the first opens by default. Defaults to one "Default" view with no filters
reportNamestringAnalyticsLiveboard only: subject of the sentence under "set as default"; defaults to "This page"
customActionsLiveboardCustomAction[]Custom actions of this Liveboard; their ids are added to visibleActions. Defaults to DEFAULT_CUSTOM_ACTIONS, [] for none. See Custom actions
embedSize'page' \| 'modal'StandaloneLiveboard only (AnalyticsLiveboard is always page). page (default) fills the page and grows with the Liveboard; modal keeps a fixed height for a modal frame

All other LiveboardViewConfig props from the SDK are forwarded via ...rest, on top of LIVEBOARD_EMBED_PROPS[embedSize] (for page: full height, Liveboard title visible, iframe sized to the viewport minus the navbar).

Look and feel (customizations)​

A page Liveboard comes with the Salesloft look: ThoughtSpot theme variables (--ts-var-*, resolved from the design tokens, as the iframe cannot see them), CSS rule overrides (tooltips, table cell alignment, the header's chips) and the Ask button of the Spotter action, styled through ASK_SPOTTER_ACTION_ID. They live in src/utils/liveboardCustomizations/. A modal Liveboard gets none.

The customizations prop complements them: it is merged key by key with the defaults (mergeCustomizations), so passing one theme variable or one CSS rule keeps all the others, and a value you pass wins over the default. Arrays replace the default whole; an undefined value is ignored.

<AnalyticsLiveboard
customizations={{
style: {
customCSS: {
variables: { '--ts-var-chip-background': 'transparent' },
rules_UNSTABLE: { '.my-class': { 'margin-top': '8px' } },
},
},
}}
/>

The defaults are built once per mount, so a theme change while the page is open shows after the next load. --ts-var-button-padding (3px and Space 200) was part of the package's previous default but the app's own customizations replaced that default, so it never applied there and is not part of the new one.


GUIDs are not accepted: liveboardId and its legacy alias pinboardId are left out of the props on purpose, so every page references Liveboards by obj_id and no GUID ties the code to one environment.


Referencing Liveboards by obj_id​

A Liveboard GUID changes between environments when content is promoted with TML, so hardcoding it ties the code to one environment. Instead, pass the Liveboard's ThoughtSpot obj_id, which travels with the TML:

<AnalyticsLiveboard viewKey="advanced_analytics_conversations_insights" liveboardObjId="ConversationsInsightsRLS-d5f75047" />

The Visual Embed SDK only embeds by GUID, so AnalyticsLiveboard resolves the obj_id first:

  1. It waits for ThoughtSpot auth (AdvancedAnalyticsProvider) to be ready, so the embed's own login happens first.

  2. useLiveboardQuery runs two TanStack Query queries at once: fetchLiveboardId (key ['advancedAnalytics.liveboardId', objId], the GUID alone) and fetchLiveboard (key ['advancedAnalytics.liveboard', objId], the GUID plus the details). The embed waits for the first unless its custom actions need the details (see Load performance). Both:

    • get a Trusted Auth token with fetchThoughtSpotToken;

    • calls ThoughtSpot through thoughtSpotRestApi (@shared/requests, {THOUGHTSPOT_URL}/api/rest/2.0) as that user, so only Liveboards the user can access are found:

      POST {THOUGHTSPOT_URL}/api/rest/2.0/metadata/search
      Authorization: Bearer <token>
      { "metadata": [{ "identifier": "<obj_id>" }] }
    • return the metadata_id (the GUID) of the entry whose metadata_obj_id is the requested obj_id. ThoughtSpot also matches identifier against GUIDs and names, so other entries are ignored. fetchLiveboard also sends "include_details": true, which makes metadata_detail non-null and is where the model views, column names and KPI viz ids come from.

  3. While auth or the query is pending, the loading state shows (loadingFallback or the default skeleton).

  4. On success, the embed renders with the resolved GUID. Everything downstream (status, filter sync, Sentry) works with the GUID as before, and Sentry reports also include the obj_id.

  5. On failure (auth, request, or no matching Liveboard), the error state shows (errorFallback or DefaultErrorState) and the error is reported to Sentry once, tagged with component and with liveboardObjId in extra.

The answers are cached by the shared QueryClient for the session, and kept in localStorage for the next one (see Load performance): the embed starts from what a previous visit stored and asks ThoughtSpot again in the background once it is older than 5 minutes. The obj_id is not validated upfront: an unknown one fails the query and shows the error state.

The ThoughtSpot cluster must allow this app's origins (every environment, including localhost.qasalesloft.com) in its CORS settings, because the browser calls the REST API directly.

obj_ids are unique per ThoughtSpot Org and are kept when a Liveboard is promoted, while its GUID changes. They can be changed with POST /api/rest/2.0/metadata/update-obj-id, which would break the reference in code.


Load performance​

A page with an AnalyticsLiveboard used to show its Liveboard after a serial chain: saved views, /v2/users, a ThoughtSpot token, a metadata/search that asks for the Liveboard's details (up to two seconds), and only then the iframe. The full analysis, the strategies that were tried and why each one was adopted or not, and every measurement are in docs/load-performance.md. What the code does today:

  1. The Liveboard is resolved above the saved views gate. AnalyticsLiveboard calls usePrefetchLiveboard before SavedViewsProvider, which renders nothing until its views are loaded. The Liveboard starts once ThoughtSpot auth is ready, and StandaloneLiveboard finds the answers in the query cache or joins the requests in flight.
  2. The embed waits for the GUID, not for the details. fetchLiveboardId asks metadata/search without include_details (about 2 KB and 0.2 s, against 142 to 910 KB and 0.4 to 2.0 s). fetchLiveboard runs at the same time, and the embed only waits for it when an action derives its scope from kpiVizIds (needsLiveboardDetails) or when a saved view selects accounts (AnalyticsLiveboard passes waitForDetails, since the account filter only applies when the Liveboard's columns include ACCOUNT_FILTER_COLUMN), because that scope and that filter are part of the embed config and a config that changes after the load reloads the iframe. modelViews and columnNames are read when an action is clicked, through loadDetails(), which resolves at once with what is known.
  3. A resolved Liveboard is kept in localStorage (liveboardCache), per cluster and user, and used as the first answer. It is asked again in the background once it is older than 5 minutes (LIVEBOARD_STALE_TIME_MS) and ignored, and deleted when read, after 24 hours or when its shape is not the expected one. Sign-out does not clear it: the key holds the user id, so another user never reads it. Bump LIVEBOARD_CACHE_VERSION when the stored shape changes. A stored GUID that is no longer valid fails the embed until the background request answers, and the status resets when the GUID changes.

Measured in one dev bundle against the QA 17 cluster, with the code before and after swapped between runs (3 runs each, median of the seconds until LiveboardRendered), the first visit of a page inside the app went from 3.4 to 2.4 s on simple, 3.7 to 2.4 s on tabs and 4.6 to 3.2 s on filters, and the prospecting page, which waits for the details, from 5.4 to 2.0 s when a stored copy exists. A full page load did not improve measurably. The numbers, their spread and their limits are in the document.


Filter sync with the host URL​

With syncFiltersWithHostUrl, AnalyticsLiveboard uses useLiveboardFilterUrlSync to keep the Liveboard filters and the URL in both directions. Users can share, bookmark, and use back/forward on a filtered view.

Only Liveboard filters are synced: the filter bar users edit. Runtime filters are not. They belong to the host app, which passes them through the runtimeFilters prop, so the prop stays their single source of truth. If a page needs its runtime filters in a shareable link, it keeps them in its own URL state and passes them through the prop.

Runtime filters also stay out of the ThoughtSpot iframe URL (the SDK default, excludeRuntimeFiltersfromURL: true). They travel in the embed payload instead, which avoids the iframe URL limits (up to 50 URL filters, and an error past 2000 characters) and allows up to 1000 filters. See Runtime filters.

URL format​

The state is stored as JSON in the tsFilters query param (DEFAULT_HOST_URL_FILTERS_PARAM). The param is removed when there are no filters, except on boards with default filters, which keep an explicit empty state. Other query params are preserved.

?tsFilters={"liveboardFilters":[...]}
KeyShapeApplied via
liveboardFilters{ column, oper, values, type?, datePeriod?, includeCurrentPeriod?, applicability? }[]HostEvent.UpdateFilters (visible filter chips, dates, ranges)

Any other key is ignored, including runtimeFilters in links created before runtime filters stopped being persisted.

The URL comes from shared links and GetFilters from a cross-origin iframe, so both are treated as untrusted and validated on every read:

  • A param longer than MAX_URL_STATE_LENGTH (8,000 characters, decoded), or one that is not valid JSON, is ignored.
  • At most MAX_LIVEBOARD_FILTERS (50) filters are kept. ThoughtSpot documents no limit for UpdateFilters, so this one is ours.
  • Unknown keys are stripped. Fields with the wrong type are dropped (strings must be non-empty strings, includeCurrentPeriod a boolean).
  • values must contain only strings, finite numbers, or booleans.
  • applicability.level must be LIVEBOARD, TAB, or GROUP.

Date filters are persisted only when their value can be sent back to UpdateFilters:

TypePersisted values
YESTERDAY, TODAY, TOMORROW, THIS_PERIOD, PERIOD_TO_DATE[""] (the documented format; the period travels in datePeriod and the operator, e.g. GE for "on or after", in oper)
EXACT_DATE[epoch], taken from dateFilter.epoch
EXACT_DATE_RANGE[lowEpoch, highEpoch]
LAST_N_PERIOD, NEXT_N_PERIOD[count]

Epochs are kept exactly as ThoughtSpot returns them (seconds, as strings), so no timezone conversion happens. Any other type, or a filter missing its value, is not persisted and never sent: the chip keeps the value saved in the Liveboard instead of receiving an empty date filter. For the same reason, an EXACT_DATE with values: [""] from older links is skipped when applying the URL.

The length limit also applies on write. If the embed's state would serialize to more than MAX_URL_STATE_LENGTH, the URL keeps the last state that fit and a warning is logged, instead of writing a state that would be dropped on reload.

With syncFiltersWithHostUrl off, the hook never reads or writes the URL. Use filtersUrlParam to give each board its own param when several synced boards share a page.

Default filters​

defaultFilters declares the Liveboard filters a board opens with. They are regular Liveboard filters: they show as chips the user can change or remove, and they filter the data.

<StandaloneLiveboard
liveboardObjId={LIVEBOARDS.prospectingAccounts.liveboardObjId}
visibleActions={GENERAL_VISIBLE_ACTIONS}
defaultFilters={[{ column: 'Region', oper: 'IN', values: ['West'] }]}
syncFiltersWithHostUrl
/>

They use the liveboardFilters shape of the URL (LiveboardFilter) and go through the same validation. Filters UpdateFilters can't apply (no column, operator or values, or a date type without its value) are dropped with a console warning.

What the board applies after render:

syncFiltersWithHostUrlURLAppliedURL written
off(not read)defaultFiltersNever
onNo param, or an invalid onedefaultFiltersYes, the resulting state (history.replace)
onA filter stateThe URL state (defaults are ignored)As usual
onAn explicit empty state ("liveboardFilters":[])Nothing (the Liveboard's saved filters)As usual
  • The sync stays explicit. defaultFilters never turns syncFiltersWithHostUrl on: writing the URL is a visible side effect, and boards sharing a page need their own filtersUrlParam.
  • Explicit empty state. On a board with defaults, a missing param means "apply the defaults". So when the user clears every filter, the URL keeps {"liveboardFilters":[]} instead of dropping the param, and the defaults don't come back on reload or back/forward. Boards without defaults still drop the param.
  • Navigation. With the sync on, a URL without the param resolves to the defaults, following the same rules as any other URL change (in-place update, or a reload when a column is dropped).
  • Read on mount. Like defaultValue in React, the defaults are read when the embed mounts or reloads (including when the SDK rebuilds it because a config prop such as runtimeFilters or activeTabId changed), and when the Liveboard changes. Changing the array afterwards does not re-apply it, so an inline array is safe.
  • Same path as URL filters. They are applied with UpdateFilters once the embed is ready (see URL → Liveboard), with the same costs.

defaultFilters are not runtime filters: they never go to runtimeFilters, so the data always matches the chips.

URL → Liveboard​

  1. Initial load: the URL filters (or the defaults) are applied with HostEvent.UpdateFilters, on top of the filters saved in the Liveboard, as soon as the embed subscribes to that event. The hook listens with embed.on(embed.subscribedEvent(HostEvent.UpdateFilters), …), which ThoughtSpot documents as the way to trigger a host event at load time: it fires once the embedded app has registered the event's handler, so the call is sent once and is never dropped. It needs SDK 1.48.0+ and ThoughtSpot 26.4.0.cl+.
    • Order of signals. On our cluster LiveboardRendered arrives first and the subscription follows within about a second. LiveboardRendered only keeps the skeleton up while filters are pending; the subscription sends them. The skeleton is released as soon as UpdateFilters settles, before the URL is written.
    • "No answer found" means applied. The SDK sends UpdateFilters through its UI passthrough channel and rejects with No answer found when ThoughtSpot replies without a value, which it also does when the filters were applied. That rejection is treated as success; any other error is logged.
    • Safety net. If the embed has not subscribed within MAX_URL_FILTERS_HOLD_MS (10 s), the skeleton is released, the board shows its saved filters, and a console warning is logged. The filters still apply if the subscription arrives later. Nothing is retried on a timer.
    • Double query. Liveboard filters cannot be applied at load in SDK 1.52 / ThoughtSpot 26.9, so ThoughtSpot queries twice and the skeleton hides the unfiltered first load. liveboardOverride (SDK 1.54.0 / ThoughtSpot 26.11.0.cl) applies filters at load and will remove this.
  2. Later URL changes (back/forward, links) while the board is mounted:
    • If they add or change filters, UpdateFilters applies them in place, with no reload. It is sent once, like the initial load. When the host passes onFilterStateChange, the loading skeleton (isApplyingInitialFilters) stays up until the board reports the applied state, or for at most MAX_URL_FILTERS_HOLD_MS, so the previous filters are not shown as the new ones. Without a listener the update is applied behind the visible board, as before.
    • If they drop a column (including a URL with no filters), the embed reloads (the hook bumps embedKey, used as the LiveboardEmbed key) and the URL is applied to the fresh load. UpdateFilters only updates the columns it receives, and ThoughtSpot supports no way to remove a filter: values: [] leaves an empty chip and does not work for date filters. The result matches opening that URL from scratch.
  3. Liveboard changes (a different liveboardObjId, so a different resolved GUID), and new SDK instances (the SDK React wrapper rebuilds the embed when a config prop such as runtimeFilters or activeTabId changes), start over as if freshly mounted: pending work for the previous board is dropped, and the URL state is applied to the new board once it renders.

Only the columns present in the URL are ever sent to UpdateFilters, and never with empty values. Filters the user did not touch, including date filters, stay exactly as the Liveboard has them.

Liveboard → URL​

On onFilterChanged (debounced 250 ms) the hook calls HostEvent.GetFilters, keeps only its Liveboard filters, and writes the URL only when the state actually changed:

  • User changes use history.push, so each change (after the debounce) gets its own history entry and back/forward walks through them. Going back applies the previous state like any other URL change.
  • The hook's own writes (the initial filters, or the state read back after applying a navigation) use history.replace, so opening a board never adds an extra entry and going back never breaks forward.

Changes the hook applies itself are ignored so they don't loop back into the URL. A consumer onFilterChanged is called right after, on every change, without the debounce.

Filters in the URL are UI state only. They are not authorization. Tenant isolation is still enforced by ts_var() RLS.

Reported filter state​

onFilterStateChange gives the host the filters the board actually holds, so it can tell when a user has moved away from a saved view. Each call is { state, source }, where state is { liveboardFilters } as GetFilters reports it:

sourceWhen
initialThe board with its initial state (URL state or defaultFilters) applied and readable, or, for a board with nothing to apply, read once without touching the URL. This is the baseline
navigationAfter a later URL change (back/forward, a link) was applied to the board and became readable. When the change drops a filter column the board cannot clear it, so the hook reloads the iframe; the state the board comes back with is reported as navigation, never as initial, so it does not replace the baseline
userAfter a filter change event that is not the settling of a state the hook applied

Settling. ThoughtSpot answers UpdateFilters before every filter is readable: with two default filters, the first GetFilters right after applying returned only one of them, and a single FilterChanged event carrying the complete state arrived about four seconds later. So while a host listens, a read right after applying is held back, neither reported nor written to the URL, until it holds every filter that was applied, with the applied values (comparing only the columns would pass a read that still has the old values of a column, which is what happens when a view with the same columns and other values is opened). A read still in flight when a new state is applied is discarded, since it describes the board before that state. The read that follows the next FilterChanged event closes the wait whatever it holds, because the board has announced itself. That read is reported with the source of the apply and written with history.replace, so opening a board adds no history entry. There are no timers or retries: only the events the hook already listens to.

Two limits of that rule: a filter that never becomes readable leaves the wait open until the next change event, and a user edit made before ThoughtSpot announces the applied state is taken as part of the baseline. Without onFilterStateChange none of this applies and the hook behaves as before, including writing the first read to the URL.

Example: a board that opens with two default filters, Created Date and Industry. The times are milliseconds since the page loaded, taken from a real run made before the wait described above existed; the last line is what a host receives now:

 9060  onLiveboardRendered     the shell loaded, nothing applied yet
10895 UpdateFilters answered; GetFilters has 1 of the 2 filters → held back, nothing reported
14581 FilterChanged event, complete state → the read that follows it
14833 onFilterStateChange({ source: 'initial', state: { liveboardFilters: [Created Date, Industry] } })

Before the wait, the read at 10895 was reported as the baseline and the one at 14833 as a user change, so Save and Reset appeared without anyone touching a filter.

A host that compares with the baseline:

const baseline = useRef<string>()

const handleFilterStateChange = ({ state, source }: FilterStateChange) => {
const current = JSON.stringify(state)

if (source === 'initial' || baseline.current === undefined) {
baseline.current = current
setIsModified(false)
return
}

setIsModified(current !== baseline.current)
}

<StandaloneLiveboard
liveboardObjId={liveboardObjId}
syncFiltersWithHostUrl
onFilterStateChange={handleFilterStateChange}
/>

useLiveboardViewBridge is this, plus the store updates for Save and Reset.

Compare each state with the baseline instead of with the saved view: ThoughtSpot normalizes the filters it reports, so what it reports can differ in form from what was saved. A filter with no value, such as an empty Rep Name chip, is not reported at all.

Known limitations​

  • Console noise from custom charts. The post-load UpdateFilters reloads the visualizations while ThoughtSpot's custom charts are still initializing, so the ThoughtSpot iframe logs errors such as Target window is not available (TSChartApp, ts-to-chart) once per load. They come from a cross-origin iframe, don't reach onError or Sentry, and the charts end up rendered with the filters applied. Applying filters at load with liveboardOverride will remove them.
  • Date chips can't be cleared with UpdateFilters, so a navigation that drops a column reloads the embed.

Date range filter​

A page Liveboard can show the date picker of the Analytics Hub reports (DateFilter from @shared/analytics) and keep the Liveboard to the chosen range. ThoughtSpot's own date filter works out "this quarter" by itself, which can disagree with the rest of Rhapsody: the Analytics Hub resolves ranges with the tenant's fiscal calendar (fiscal_year_periods.json), so a tenant whose fiscal year closes in February sees its own quarters there. Using the same picker and the same resolution keeps both pages on the same dates.

It is opt-in per Liveboard: pass the name of its date column.

<AnalyticsLiveboard
viewKey={LIVEBOARDS.teamPerformance.viewKey}
liveboardObjId={LIVEBOARDS.teamPerformance.liveboardObjId}
dateColumn={LIVEBOARDS.teamPerformance.dateColumn}
/>

LIVEBOARDS.teamPerformance.dateColumn is 'Date'. Without dateColumn nothing changes: no picker, no filter.

Decisions​

DecisionWhy
The picker is the shared DateFilter, with the options of the Analytics Hub reports, fiscal ones includedSame tokens (-7d, 0fq, today), same resolution (getQueryDates), so a range reads and means the same on both pages
The range is part of the open saved view (from, to)It is saved with the view and Reset restores it. DateFilter is given the saved views store as its useParams, as the Analytics Hub reports do
The URL mirrors the host params through useHostParamsUrlSync; rewriteUrlOnSave stays falserewriteUrlOnSave rewrites the whole search from buildParametersToPersistToURL, which defaults to () => ({}), so turning it on would erase tsFilters and the pages' own params
There is always a range; the default is the last 7 days (DEFAULT_DATE_RANGE)With the Liveboard's own date filter removed and no range applied, every tile of Team performance failed with Query execution failed (observed 2026-11-03). The default is the one DateFilter opens on in the Analytics Hub reports
The range the embed opens with goes in runtimeFilters; later ranges are sent with HostEvent.UpdateRuntimeFiltersChanging runtimeFilters makes the SDK build a new iframe. The event repaints in place: onLoad did not fire again and the tiles repainted within 10 s (observed 2026-11-03)
Each event carries every runtime filter, not only the dateThe ThoughtSpot docs do not say whether the event replaces the filters the embed loaded with or adds to them
If the event is rejected the embed is rebuilt with the current rangeA board showing the previous range under a picker that shows the new one would be wrong numbers presented as right
A fiscal range waits for the fiscal calendar, and is unavailable if it cannot be loadedWithout periods the date service falls back to the calendar quarter or year without saying so. That would be another period's numbers under a fiscal label
No weekend toggle and no comparison periodThe Analytics Hub applies "weekends off" as a Cube segment; a runtime filter on a date column is a range and cannot express it. With weekends off the Analytics Hub showed 99 emails against 105 for the same year (2026-11-03), so the two pages differ if a user turns it off there

Host params in the URL​

The host owns these params; the Liveboard owns tsFilters. useHostParamsUrlSync keeps them in step between the saved views store and the URL, leaving every other param alone:

ParamValueWritten as
from, toA relative token (-7d, 0q, 0fq, today) or an ISO datefrom=-7d
account_id, group_idThe user / group selectiongroup_id[]=all
crm_account_idThe CRM account selectioncrm_account_id[]=42
/advanced-analytics/team-productivity?from=0fq&to=today&group_id%5B%5D=all&tsFilters=%7B%22liveboardFilters%22%3A%5B%5D%7D
  • Store → URL: a change in the store is written with history.push, so back and forward walk through the user's changes. The first write after the page opens uses replace, so opening adds no history entry.
  • URL → store: when the URL changes to a host state the store does not hold (back, forward, a link), the store takes it with replaceParams.
  • The saved views store reads the URL when the page opens, so a shared link restores the range and the selections. The user, group and account selections were not in the URL before.
  • Only the params the host owns are touched. useLiveboardFilterUrlSync now compares only its own tsFilters when the URL changes, so a change to from or to does not re-apply the Liveboard's filters or put the skeleton up, and it reads the current search when it writes, so it no longer overwrites a host param written while a GetFilters was in flight.

How a range reaches the board​

useDateRangeFilter reads from and to from the open view (default -7d to today) and resolves them with getQueryDates to the first and last second of the days in the browser's time zone, as epoch seconds, the unit ThoughtSpot takes for date runtime filters. StandaloneLiveboard receives it as dateFilter and useDateRuntimeFilters applies it.

The range -7d to today on 2026-11-03, in a browser at UTC-6, as it was sent in a real run:

[
{
"columnName": "Date",
"operator": "BW_INC",
"values": [1793167200, 1793771999]
}
]

1793167200 is 2026-10-28T00:00:00-06:00 and 1793771999 is 2026-11-03T23:59:59-06:00. The first range goes in the embed's runtimeFilters; picking another one afterwards triggers HostEvent.UpdateRuntimeFilters with the full list (the date filter plus the user, group and account filters), without rebuilding the iframe.

range known ──► embed mounts with runtimeFilters = user + account + date
user picks a range ──► UpdateRuntimeFilters(user + account + date)
├─ ok ──► the board repaints in place
└─ rejected ──► reported to Sentry, embed rebuilt with the current range

Status of the date filter (DateFilterConfig.status):

StatusWhenWhat the page shows
loadingA fiscal range and the fiscal calendar is still being fetchedThe skeleton; the embed is not mounted
readyThe range is resolvedThe Liveboard, filtered
unavailableA fiscal range and the fiscal calendar could not be loaded, or the range cannot be read or ends before it startsThe error state ("This date range is unavailable")

Drill-down follows the range​

The "Drill Down" modals filter their records by the range the picker shows now, not the one the embed opened with. ThoughtSpot's click payload carries its own native date filters but not the runtime filters, so withRuntimeFilters adds a BW_INC runtime filter as a time dimension on the view's matching dimension (a range the click already carries on that member wins). useDateRuntimeFilters returns currentRuntimeFilters for this; the embed's runtimeFilters stays the opening range on purpose, because changing it rebuilds the iframe. Checked in QA on Team performance and Call sentiments: with Last 7 Days the Emails Sent KPI and its modal both show 3, with This Year both show 108, and a change made in the picker without reloading is reflected in the next drill-down.

Saved views​

from and to are in SAVED_VIEW_PARAMS, so a view that was saved with a range keeps it:

"view_params": {
"tsFilters": "{\"liveboardFilters\":[]}",
"from": "0fq",
"to": "today",
"view_created_by": 13505
}

A view saved before this change has no from or to and opens on the default range. A range different from the open view's marks it as modified, so Save and Reset appear. Translating the date filters that older views hold inside tsFilters is not done.

What the Liveboard needs​

  • Its own date filter on that column must be removed in ThoughtSpot. The Liveboard's Cube view takes one time filter: with the native "Date This Year" filter and a runtime filter on Date, every tile failed with View 'team_performance' supports only one time dimension.
  • The column name must be exact. On Team performance a runtime filter on Created Date, which is not a column there, changed nothing and raised no error.
  • The Liveboard must be built from a Model: runtime filters do not apply to Tables or Views.

What was observed​

On Team performance (Date, All Users, 2026-11-03), through UpdateRuntimeFilters:

RangeEmails sentCalls loggedAnalytics Hub, same range
This year10526105 and 26
This quarter020 and 2
This week000 and 0

The counts match. The rates do not: for this year the Analytics Hub showed % Personalized 9.9%, % Opened 9.9%, % Replied 4.4% and % Positive replies 0.0%, against 8.57%, 7.62%, 3.81% and 2.86% in the Liveboard. They are different metric definitions, not a date issue. This tenant's fiscal calendar equals the calendar: 0fq resolved to 1 October. With a fiscal year starting on 1 February simulated in the browser, the board showed 97 emails against 105 from 1 January.

Limits of the date range filter​

  • UpdateRuntimeFilters takes about 30 s to resolve ({}), although the board repaints within 10 s. Nothing waits for it.

  • Whether the event adds to or replaces the filters the embed opened with was not verified. The user filter had no visible effect on Team performance, so the test could not tell. Every event carries the full list for that reason.

  • Time zone. Days start and end in the browser's zone. Whether ThoughtSpot or the Cube view cut days in the organization's zone was not checked.

  • Every Liveboard of the app has a dateColumn, and its own date filter must stay removed in ThoughtSpot. With both, the Cube view fails with supports only one time dimension; with neither, with Time dimension required for view. Observed 2026-11-05 on the dev instance, with the range set to This Year and All Users:

    PageTabdateColumnObserved
    Team ProductivityTeam performanceDatetiles load
    Team ProductivityProcessDatetiles load
    Team ProductivityCall sentimentsEvent Attiles load
    Team ProductivityCall dispositionEvent Attiles load
    Prospecting resultsAccountsactivity_attiles load. Date range, the label of the removed filter, is not a column: every tile failed with Time dimension required
    Prospecting resultsTeam cadencesActivity Attiles load
    Prospecting resultsCallsEvent Attiles load
    Prospecting resultsEmailsSent Attiles load
    Prospecting resultsMeetingsBooked Attiles load

    Checked again on 2026-11-06, once the native date filters were removed in ThoughtSpot again: all nine tabs load with no native date chip, and their figures change between This Year and Last 7 Days (Team performance 108 emails and 31 calls against 3 and 5; the range also updates in place from the picker, with no reload). The four tabs that have an Analytics Hub page were compared with it, This Year, all users, weekends included in the Hub (weekends=true). Team performance: 108 emails and 31 calls in both, and This Fiscal Quarter 3 and 7 in both. Process: Tasks Completed 44, Tasks Deleted 0, % On-time tasks 54.5%, Enrolled 31 and Hot Leads 4 match, but People Contacted is 38 in the Hub and 2 here (its tile here is subtitled Week of Oct 23, which suggests it shows the last period, not verified). Call sentiments: Calls Logged 31, Positive Calls 25, Voicemails 0 and Connected Calls 5 match, but the positive rate is 500.0% in the Hub (positive over connected calls) and 80.0% here (positive over logged calls). Call dispositions: the same four counts match, and Connected 74% here is the Hub's 23 connected calls of 31. The two differences are in how the Liveboard's tiles are defined, not in the date filter. With the Hub's default, weekends=false, its Team performance emails were 102 against 108 here, because this page does not exclude weekends (see Limits of the date range filter). The five Prospecting tabs were not compared.

  • The Spotter panel does not get the range. It restarts its chat when its filters change, which would happen on every range change.

  • The fiscal calendar is cached in localStorage under one key (all_fiscal_year_periods) shared by every user of the browser, and DateFilter fetches it too, so it is requested twice on a page with a date filter. Both are the shared date service's behaviour.

  • A range such as "today" is resolved when the page renders, not at midnight.

  • Weekends and comparison periods are not offered. See the decisions table.


Saved views of a Liveboard​

Users can save the filters they applied to a Liveboard, reopen them later and share them with their team. The picker, the Save / Reset buttons, sharing and the default view are the ones Analytics Hub pages already use (@shared/saved-views), and views are stored through Melody's existing saved_list_views endpoints. No new endpoint was created. The package adds the one thing the engine cannot know, what the Liveboard actually holds, through onFilterStateChange, and ties both together. Every page Liveboard has them: AnalyticsLiveboard requires a viewKey. The pages of the example app (ProspectingResults, TeamProductivity) all pass the same one.

 ┌────────────────── AnalyticsLiveboard ──────────────────────────────────────────┐
│ LiveboardToolbar useLiveboardViewBridge AnalyticsLiveboard │
│ picker + Save / Reset ◄── isModified ◄── onFilterStateChange │
│ │ │ ▲ ▲ │
└──────────┼──────────────────────────┼──┼──────────────────────────┼─────────────────┘
▼ ▼ │ URL param `tsFilters` │ GetFilters / UpdateFilters
@shared/saved-views ─────► Melody saved_list_views ThoughtSpot Liveboard
(store, picker) (one `view` per Liveboard)

Decisions​

DecisionWhy
Reuse saved_list_views; create no endpointThe direction given to the team: reuse the existing endpoints and, when one needs a change, send the backend team a detailed list. Creating a view under a view Melody does not list returns 422; Melody supports every advanced_analytics_* key of LIVEBOARDS (see The view key)
Every AnalyticsLiveboard has saved views, so viewKey is requiredEvery page works the same way, so there is no switch to maintain: no optional views, no lazy loading, no prop combinations to forbid. The embed without views still exists as StandaloneLiveboard because Liveboards opened in the analytics modal have none
One Melody view key, passed as the viewKey propThe key is a property of the page, not of the component. Every Liveboard in LIVEBOARDS declares its own (viewKey), named after the Analytics Hub page it replaces (see The view key)
The filter state is one JSON string in view_params.tsFiltersMelody never sees nested objects or camelCase keys, so there is no schema or casing concern on its side. It is the same string the Liveboard keeps in the URL
Defaults live inside the "Views from Salesloft"As in Analytics Hub: the default filters are the content of a view, not a separate prop. A user's own default view replaces them without merging
No mapping from Analytics Hub report viewsfrom/to/group_id/columns/sort have no reliable ThoughtSpot equivalent and the mapping was too costly to maintain. Liveboard views use their own view key, so the two never mix
A view is "modified" when the board differs from its baseline, not from the saved stringThoughtSpot normalizes the filters it reports, so they can differ in form from what was saved. The baseline is what the board reports once the view is applied
The URL belongs to the LiveboardThe saved views store is told not to write it (rewriteUrlOnSave={false}); the bridge only sets the tsFilters param when a view opens
The URL wins over the open view when the page opensA shared link or a reload carries the filters the user wants to see, so the bridge does not overwrite a valid tsFilters that differs from the view's own. The default view stays selected and is flagged as modified, so Save and Reset appear. Picking a view or pressing Reset afterwards sets the URL from the view again
Tabs are not part of a viewThe Liveboard does not use them, and SDK 1.52.1 has no tab-changed event (GetTabs returns only id and name)

What a view stores​

The request the engine sends when a user saves a view (shortened):

{
"view": "advanced_analytics_prospecting_accounts",
"name": "CPG and Consulting",
"is_default": false,
"shared": false,
"view_params": {
"tsFilters": "{\"liveboardFilters\":[{\"column\":\"Industry\",\"oper\":\"IN\",\"values\":[\"CPG\",\"Consulting\"]}]}",
"view_created_by": 13505
}
}

tsFilters is JSON.stringify of the PersistedFilterState the Liveboard reports: the same shape as defaultFilters, up to 50 filters. view_created_by is stamped by the engine and is how the picker shows who shared a view. These keys, the date range (from, to, see Date range filter) and the user, group and account selections are saved (SAVED_VIEW_PARAMS); anything else in the store is dropped by toSavedViewParams.

The view key​

Saved views are stored in Melody under a view string, and AnalyticsLiveboard takes it as the viewKey prop. The app declares one per Liveboard, in the same object as its obj_id, so a Liveboard cannot be added without it:

// apps/advanced-analytics/src/liveboards.ts
export const LIVEBOARDS = {
prospectingAccounts: {
liveboardObjId: 'NewLiveboard-23f9fb7f',
viewKey: 'advanced_analytics_prospecting_accounts',
tabPath: 'accounts',
tabLabelKey: 'prospectingTabAccounts',
displayBy: {
param: 'display_by',
tables: ['dayOfWeek', 'hourOfDay', 'user', 'name'],
},
},
/* … */
} as const satisfies Record<string, LiveboardConfig>
<AnalyticsLiveboard
viewKey={LIVEBOARDS.prospectingAccounts.viewKey}
liveboardObjId={LIVEBOARDS.prospectingAccounts.liveboardObjId}
/* … */
/>

Naming. The key is the one the Analytics Hub page being migrated saves its views under, with advanced_ in front: analytics_prospecting_accounts becomes advanced_analytics_prospecting_accounts. The Analytics Hub names were read from the view parameter of the saved_list_views requests each hub page makes (on 2026-11-05, in QA) and match the ones in apps/analytics-hub:

Liveboard (LIVEBOARDS)Analytics Hub pageAnalytics Hub viewviewKey here
prospectingAccountsprospecting/accountsanalytics_prospecting_accountsadvanced_analytics_prospecting_accounts
prospectingTeamCadencesprospecting/team-cadencesanalytics_prospecting_team_cadencesadvanced_analytics_prospecting_team_cadences
prospectingCallsprospecting/callsanalytics_prospecting_callsadvanced_analytics_prospecting_calls
prospectingEmailsprospecting/emailsanalytics_prospecting_emailsadvanced_analytics_prospecting_emails
prospectingMeetingsprospecting/meetingsanalytics_prospecting_meetingsadvanced_analytics_prospecting_meetings
teamPerformanceproductivity/team-performanceanalytics_team_productivity_team_performanceadvanced_analytics_team_productivity_team_performance
processproductivity/processanalytics_team_productivity_processadvanced_analytics_team_productivity_process
callSentimentsproductivity/call-sentimentsanalytics_team_productivity_call_sentimentsadvanced_analytics_team_productivity_call_sentiments
callDispositionproductivity/call-dispositionsanalytics_team_productivity_call_dispositionsadvanced_analytics_team_productivity_call_dispositions

A name that does not start with analytics would get advanced_analytics_ in front instead, so the prefix is never repeated. liveboards.spec.ts checks that every Liveboard has a key, that no two share one, and that none is cadence_people.

The advanced_ prefix keeps these records apart from the hub page's: a view saved on one never shows on the other, which is what the two need while their filters are not compatible. Once the hub page is gone, the key stays as it is, so no saved view is orphaned.

Melody must accept the keys. Melody only accepts the view types in its own list when a view is created, and a view outside it makes create return 422. Listing, updating and deleting do not validate view. Melody accepts all nine advanced_analytics_* keys of LIVEBOARDS. Checked on 2026-11-06 from the local app against QA (localhost.qasalesloft.com): on each of the nine tabs, saving a private view returned "View Saved" and the view was then deleted. Melody's code is not in this repository, so its list was not read. A key added to LIVEBOARDS later has to be added to Melody's list first, or saving under it fails.

The sections of the picker​

SectionWhere the views come fromEditable by the user
Views from SalesloftDeclared in the client with buildStandardViews; never storedNo: saving one creates a copy under "Your saved views"
Your saved viewsThe user's own views from MelodyYes
Views shared with youOther users' views saved with shared: true, same teamNo

The engine puts a view under "Views from Salesloft" when its isDefault is true and, if that flag is missing, when Melody's is_default is true. is_default only means "the default of whoever saved it", so a view saved as default would jump sections. keepUserViewsInTheirSection sets isDefault: false on every view that has no flag, and SavedViewsControls passes its result to the picker and the buttons. The engine is shared with other apps and is left untouched.

Views from Salesloft​

buildStandardViews turns definitions into the views of the first section. As in Analytics Hub they are declared in the client and all are flagged isDefault; the first definition is the one that opens until the user picks another. Their ids are their positions (0, 1, 2), so appending a definition never changes the id of an existing one: the engine stores a user's default view by id in the browser, and ids that depended on the length would make a returning user's default point to another view. The engine sorts views by id and opens the first one flagged default, so the first definition (id 0) opens, and it is also the view the engine selects after the open view is deleted (it needs an id 0). Only append definitions: removing or reordering one changes what a stored id means. A saved view cannot collide with these ids as long as the ids Melody gives the account's views for the key are larger than the number of definitions; the code does not check it. With no definitions (standardViews={[]}) the page uses the single "Default" view, because the engine cannot start without one.

import { buildStandardViews } from '../../savedViews/standardViews.ts'

const standardViews = buildStandardViews([
{ name: 'Default', filters: [{ column: 'Region', oper: 'IN', values: ['West'] }] },
{ name: 'Consumer goods', filters: [{ column: 'Industry', oper: 'IN', values: ['CPG'] }] },
])

// [
// { id: 0, name: 'Default', isDefault: true,
// view_params: { tsFilters: '{"liveboardFilters":[{"column":"Created Date", ... }]}' } },
// { id: 1, name: 'Consumer goods', isDefault: true,
// view_params: { tsFilters: '{"liveboardFilters":[{"column":"Industry","oper":"IN","values":["CPG"]}]}' } },
// ]

Every view carries its own filters, so one from Salesloft and one a user saved open through the same path, and the Liveboard needs no defaultFilters.

A user can pick any view as their default from the picker. The engine keeps that choice in the browser (localStorage, per user and view key) and opens it on the next load instead of the first view from Salesloft.

What happens when​

EventWhat happens
The page opens with no tsFilters in the URL (or an invalid one)The store opens the user's default view, or the first from Salesloft. The bridge sets the tsFilters param to that view's own filters and the Liveboard applies them
The page opens with a valid tsFilters that differs from the open view's ownThe URL is left untouched and the Liveboard applies it. The store still opens the default view. When the board reports its first state (initial) the bridge compares it with the view's own filters (areStatesEqual, both normalized); if they differ, or the view has no filters, the view is flagged as modified and the state is copied into the store, so Save stores what is on screen. A URL equal to the view's own string opens the view as usual
The Liveboard finishes applying themonFilterStateChange reports initial: the baseline. Save and Reset stay hidden
The user changes a chipA user state arrives, differs from the baseline, and the bridge flags the view as modified and copies the state into the store. Save and Reset appear
The user changes it backThe state equals the baseline again and the buttons hide
Save, view the user ownsThe saved string becomes the view's own. The bridge takes the current state as the new baseline and the buttons hide, with no reload
Save as a new viewThe store selects the new view. The URL already holds that state, so nothing is applied and the last state read becomes its baseline: no reload
ResetThe store puts the view's own params back; the bridge sees it moving away from what it wrote, forgets the baseline and sets the URL param to the view's filters. The Liveboard applies them in place (or reloads when they drop a column) and the state it reports next, a navigation, is the new baseline
The user opens another viewThe bridge forgets the baseline and sets the URL param to that view's filters. The Liveboard applies them in place and reloads only if they drop a filter column. The skeleton stays up until the board reports them, and that first report, a navigation, is the new baseline; the buttons stay hidden meanwhile
The page reloadsUnsaved changes are kept: the URL holds them, so the page opens the default view flagged as modified, with those filters

The baseline is reported only once every filter the host applied is readable, which can take a few seconds. See Reported filter state.

Using it​

Give AnalyticsLiveboard a viewKey, and optionally the views from Salesloft. Any other prop is passed through (visibleActions, customActions, frameParams…).

<AnalyticsLiveboard
viewKey={LIVEBOARDS.prospectingAccounts.viewKey}
liveboardObjId={LIVEBOARDS.prospectingAccounts.liveboardObjId}
visibleActions={GENERAL_VISIBLE_ACTIONS}
customActions={DEFAULT_CUSTOM_ACTIONS}
/>

With no standardViews the page has one view from Salesloft, "Default", with no filters: the Liveboard opens as it was saved in ThoughtSpot. A page whose Liveboard should open with filters declares them as a view:

const standardViews = useMemo(
() => [
{
name: messages.standardViewName,
filters: [{ column: 'Industry', oper: 'IN', values: ['CPG'] }],
},
],
[]
)

<AnalyticsLiveboard
viewKey={LIVEBOARDS.myPage.viewKey}
reportName={messages.reportName}
standardViews={standardViews}
liveboardObjId={LIVEBOARDS.prospectingAccounts.liveboardObjId}
visibleActions={GENERAL_VISIBLE_ACTIONS}
customActions={DEFAULT_CUSTOM_ACTIONS}
/>

What it renders: the picker and the Save / Reset buttons (Save and Reset appear only when the user changes something) above the Liveboard.

PropDescription
viewKeyRequired. The Melody view the views are stored under. See The view key
standardViewsOptional. { name, filters }[] for the "Views from Salesloft"; the first one opens by default. Defaults to one "Default" view with no filters. Keep the array stable (useMemo when a name comes from messages)
reportNameOptional. Subject of the sentence under "set as default" in the save dialog: "{reportName} will show these filters by default when you first land on the page". Defaults to "This page"
liveboardObjId and the restPassed to the embed

The types keep the props the views layer owns out of reach: defaultFilters (the filters come from the open view), syncFiltersWithHostUrl, filtersUrlParam and onFilterStateChange, and embedSize (a page Liveboard is always page-sized). A Liveboard that needs them, such as one in a modal, is a StandaloneLiveboard.

With more than one view from Salesloft:

const standardViews = useMemo(
() => [
{
name: messages.standardViewName,
filters: [{ column: 'Region', oper: 'IN', values: ['West'] }],
},
{
name: messages.consumerGoodsViewName,
filters: [{ column: 'Industry', oper: 'IN', values: ['CPG'] }],
},
],
[]
)

How it is built​

AnalyticsLiveboard is StandaloneLiveboard with the views layer around it, in one component.

// components/AnalyticsLiveboard/AnalyticsLiveboard.tsx (shortened)
const history = useHistory()
useLayoutEffect(() => {
setFiltersParam(history, undefined)
}, [history])

<SavedViewsProvider
viewName={viewKey}
defaultView={buildStandardViews(standardViews ?? [defaultView])}
parametersToSaveViewMapper={toSavedViewParams}
rewriteUrlOnSave={false}
hasPublicSavedViews
>
<LiveboardToolbar>
<SavedViewsControls isModified={isModified} reportName={reportName} />
</LiveboardToolbar>
<StandaloneLiveboard
{...liveboardProps}
frameParams={frameParams}
onFilterStateChange={handleFilterStateChange}
syncFiltersWithHostUrl
/>
</SavedViewsProvider>
  • rewriteUrlOnSave={false}: the store would otherwise rewrite the URL with only the params it knows and drop the Liveboard's tsFilters.
  • setFiltersParam(history, undefined) on mount: with public views the store merges every URL param into its state when it loads, so a leftover tsFilters would make the open view look modified from the start.
  • hasPublicSavedViews: turns on the "shared with you" section and the per-browser default.
  • No key on the embed: a view change, a Reset and a new saved view do not remount it. The bridge sets the URL param and the Liveboard follows it like a navigation: filters that are added or changed are applied in place, and it reloads itself only when the new view drops a filter column (see Filter sync with the host URL).
  • pristine={!isModified} goes to the picker and to the buttons. The store's own pristine compares with the saved string and would be wrong.
  • frameParams defaults to calc(100vh - 120px) (SAVED_VIEWS_FRAME_PARAMS), which leaves room for the toolbar. Pass your own to change it.

LiveboardToolbar is the row above the Liveboard. Every Liveboard page has the same one and it knows nothing about views: it only lays out its children. Today the only child is SavedViewsControls (the picker and Save / Reset). More actions (for example a user or group filter) will be added to this row by changing the code, so none of it is a prop of AnalyticsLiveboard. Both use Box (see UI conventions):

// components/SavedViewsControls/SavedViewsControls.tsx
const views = useMemo(
() => keepUserViewsInTheirSection(savedViews),
[savedViews]
)

return (
<>
<SavedViewSelector savedViews={views} pristine={!isModified} /* … */ />
<PublicSavedViewActionButtons savedViews={views} pristine={!isModified} /* … */ />
</>
)
// components/LiveboardToolbar/LiveboardToolbar.tsx
<Box flex items-center gap-100 p-b-100 p-i-200>
{children}
</Box>

A control that must change what the Liveboard shows and be saved in the view needs its own wiring to the Liveboard and to view_params; adding it to the row is not enough, and that design does not exist yet.

Adding a Liveboard page​

  1. Declare its entry in LIVEBOARDS (src/liveboards.ts): obj_id, its own viewKey named as in The view key, tabPath, tabLabelKey (add the message to pages/messages.ts) and, if it has a Display By picker, displayBy. A new table goes in DISPLAY_BY_TABLES.
  2. Add it to the tabs of its page in PAGES. A new page is a file that renders TabbedLiveboardPage with a title and its PAGES entry, plus a <Route path=".../:tab?"> in AdvancedAnalytics.tsx. Outside this app, render AnalyticsLiveboard with the viewKey (and standardViews when it should open with filters).
  3. Register the route (and one path per tab) and nav item in entry.ts, and regenerate the route types (node ./infrastructure/scripts/generateGeneratedRoutes.mjs, then format the generated files with Biome).

Only one AnalyticsLiveboard can be on a page, because the filter state lives in a single URL param (tsFilters). Liveboards in the analytics modal are StandaloneLiveboards: they have no views and do not touch the URL.

Where the code lives​

PieceLives in
AnalyticsLiveboard, SavedViewsControls, LiveboardToolbar, StandaloneLiveboardsrc/components/
useLiveboardViewBridge: the baseline, isModified and Resetsrc/hooks/useLiveboardViewBridge/
buildStandardViews, keepUserViewsInTheirSection, setFiltersParam, toSavedViewParams and the typed access to @shared/saved-viewssrc/utils/savedViews/
onFilterStateChange and the wait for the applied filterssrc/hooks/useLiveboardFilterUrlSync/
The Liveboard, the viewKey, the views from Salesloft and the custom actionsThe app: they are configuration of one page

The package depends on @shared/saved-views; if a type check says it cannot find that module, run pnpm install.

Backend​

Saved views use Melody's public API as it is (${API_GATEWAY_URL}/v2/saved_list_views, called by @shared/saved-views). Creating a view validates view against SavedListView::Views::VALID_VIEW_TYPES; listing, updating and deleting do not. A view outside that list makes create return 422, which is what the first attempt with a key of our own got. Melody accepted a create under every advanced_analytics_* key of LIVEBOARDS (tried on each of the nine tabs, 2026-11-06, QA). A key added later needs a Melody change before a user can save a view (see The view key).

Known limitations​

  • Opening another view and Reset change the filters in place. The loading skeleton stays up until the board reports the applied state, so the previous filters are never shown as the new ones; it can take a few seconds (about 3.7 s was observed in the QA cluster when applying filters on load). If the board never reports, the skeleton is released after MAX_URL_FILTERS_HOLD_MS (10 s). The Liveboard reloads, with its own loading state, only when the new view drops a filter column, because UpdateFilters cannot remove a filter.
  • The new baseline is the first state reported after the switch. While the skeleton is up the user cannot edit the board, so nothing can slip into the baseline, except after that 10 s release.
  • Saving a new view applies nothing (the board already shows that state), so it has no loading state.
  • Unsaved changes are lost on reload, and a link does not carry them. Share a view instead.
  • If the modified state equals another saved view's state exactly, the store selects that view (a behavior of the engine).
  • The user's default view is stored in the browser, so it does not follow them to another browser or device. Melody also stores is_default when a view is saved as default, but it is ignored.
  • A filter edit made before ThoughtSpot announces the applied state becomes part of the baseline, and a filter that never becomes readable keeps the baseline waiting until the next change event.
  • Tabs are not saved in a view.
  • Filters that an action puts in the URL (createNavigateAction with liveboardFilters) are not applied: AnalyticsLiveboard clears tsFilters when it mounts so a leftover value does not make the open view look modified. Navigating to a page with the clicked filters therefore opens the destination's own view instead.

Troubleshooting​

SymptomCauseWhat to do
Save and Reset are visible right after the page loadsThe baseline was taken before the board settled, or the host passes the store's pristinePass onFilterStateChange to AnalyticsLiveboard and pristine={!isModified} to the picker and the buttons
Saving returns 422The viewKey is not one of the view types Melody acceptsUse a valid key (see The view key). The response should name the field view
A view saved as default appears under "Views from Salesloft"The engine reads Melody's is_default as "view from Salesloft"Pass the views through keepUserViewsInTheirSection
Cannot find module '@shared/saved-views'The package declares it in package.json but it is not installedRun pnpm install
The filters of a view do not open, or only some of them doA state holds up to 50 filters and 8,000 characters; beyond that part or all of it is ignoredSave fewer filters

UI conventions for host pages​

Pages and toolbars follow the design system rules: no inline style objects, layout through Box (@starlight/layout), text through Text (@starlight/typography), custom CSS that Box props cannot express in a styled-components file, and one component per file.

// Not this
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 16px' }}>

// This
<Box flex items-center gap-100 p-b-100 p-i-200>

Box takes boolean props (flex, flex-col, items-center, justify-between, overflow-auto…) and scaled props (gap-100, p-i-200, m-b-s-100, size-i…). The scale comes from the tokens: 100 is 8px and 200 is 16px. Text takes as (default p), color and variant, for example variant="Body/md/regular". The frame size passed to the embed (frameParams) is embed configuration, not a React style.


Error handling​

Auth errors​

When ThoughtSpot auth (AdvancedAnalyticsProvider) reports status === 'error', the component renders errorFallback (or DefaultErrorState). It also calls captureException with the auth error (if present), tagged with component: 'AnalyticsLiveboard', the liveboardObjId, and the resolved liveboardId when auth fails after resolution. The report runs from an effect, once per error and board, not on every render.

Any auth failure before the first successful login switches the provider to error. After that, EXPIRY and IDLE_SESSION_TIMEOUT failures are ignored because the SDK emits them on every normal token refresh and recovers on its own. Other failure types (SDK, NO_COOKIE_ACCESS, OTHER, …) still switch to error. If resolving the user locale fails, the provider falls back to en-US and authenticates anyway.

Embed errors​

Embed errors are reported to Sentry once per distinct error:

  • Error instances are sent directly
  • Non-Error values (SDK payloads) are wrapped in new Error('ThoughtSpot embed error: <code | errorType | UNKNOWN>'), so each failure kind gets its own readable Sentry issue
  • Duplicates are dropped. An error with the same code, errorType and first message as one already reported for the current embed is not sent again. The filter resets when the liveboardId changes or the embed remounts.
  • Every report carries the component: 'AnalyticsLiveboard' tag (filterable in Sentry). The extra includes liveboardId (the resolved GUID), liveboardObjId, hasRendered, and error. error is an allowlisted report from toEmbedErrorReport, never the raw SDK payload: { code?, errorType?, messages }, with at most 5 messages of up to 200 characters each.

The raw payload can echo query data (column names, filter values), so it never leaves the browser as-is. For the same reason, the filter sync hook logs only this report to the console, because console output ends up in Sentry breadcrumbs. Messages are truncated but not scrubbed, so a short message can still include a value.

The onError callback (if provided) receives every occurrence, duplicates included, with the untouched original error.


Token flow and rotation​

Token refresh is driven by the SDK, not a manual timer. There is no client-side token cache — analytics-api caches short-lived tokens server-side per (tenant, user).

AdvancedAnalyticsProvider mounts (ThoughtSpot auth)
│
└─ init() called once with a getAuthToken callback.
│
├─ embed mounts and its iframe boots ─┐ in parallel
│ │
└─ SDK calls getAuthToken() on init and on every │
token expiry │
│ ─┘
└─ fetchThoughtSpotToken() → GET /v2/thoughtspot/embed_token
Always issues a fresh network request — tokens are
single-use.
  • callPrefetch: true is set on init() so ThoughtSpot's static assets warm in parallel with the handshake
  • Token rotation: ThoughtSpot Trusted Auth tokens are single-use. getAuthToken always calls fetchThoughtSpotToken() directly, uncached

Custom actions​

A custom action is an entry ThoughtSpot shows (by default in the chart context menu) plus what happens when it is clicked. Actions are plain objects, declared anywhere — in this package, in an app, or inline in a page — and each AnalyticsLiveboard picks the ones it offers. Building a new app walks through a full example; this section is the reference.

<AnalyticsLiveboard viewKey={LIVEBOARDS.myPage.viewKey} liveboardObjId={…} customActions={[recordsDrilldownAction, openAccount]} />

AnalyticsLiveboard adds their ids to visibleActions (ThoughtSpot hides custom actions it doesn't name), hands the SDK only their plain fields, and runs the clicked one. Without customActions a Liveboard offers DEFAULT_CUSTOM_ACTIONS; [] offers none.

Declaring an action​

import { defineCustomAction, toRuntimeFilters, StandaloneLiveboard } from '@shared/advanced-analytics'

export const openAccount = defineCustomAction({
id: 'open-account',
label: () => messages.openAccount,
scope: { metadataIds: { vizIds: [PIPELINE_VIZ_ID] } },
run: (click, { modal, refuse }) => {
const target = TARGETS_BY_VIZ[click.vizId ?? '']
if (!target) return refuse('NO_TARGET', click.vizId)

modal.open({
title: messages.account,
content: (
<StandaloneLiveboard
embedSize="modal"
liveboardObjId={target.liveboardObjId}
runtimeFilters={toRuntimeFilters(click, { columns: target.columns })}
/>
),
})
},
})
  • click (DrilldownClick) is the normalized payload: actionId, vizId, measures and attributes ({ name, value, column }), the clicked model, and raw — the SDK payload as received, whose shape ThoughtSpot may change.
  • scope is resolved by ThoughtSpot itself (metadataIds, dataModelIds, orgIds, groupIds), so an action only shows where it applies. It can also be a function of the Liveboard's metadata ({ kpiVizIds }); returning null leaves the action out of that Liveboard.
  • The context gives every action:
    • modal — open(frame), back(), close() on the shared analytics modal. Opening while it is open stacks the frame, with a back button.
    • navigate(route) — closes the modal and pushes an in-app route; absolute URLs, //host and paths with a query or hash are refused. liveboardFilters are written to the destination's filters param (tsFilters by default), so a page with syncFiltersWithHostUrl opens filtered. A URL filter state replaces the destination's defaultFilters, so merge them back with mergeLiveboardFilters; with no liveboardFilters, the destination opens with its defaults.
    • refuse(reason, detail) — gives up on the click and reports it.
    • modelViews, columnNames and loadCubeMeta() — what the records drilldown needs to build Cube queries. columnNames maps each model column to the table column (Cube member) it reads from, so a column's display name can differ from its member.

Refusals, errors thrown by run and duplicate ids are reported to Sentry as Custom action "<id>" refused: <reason>, tagged component: 'useLiveboardCustomActions', customAction and refusalReason, once per action, reason and detail. With duplicate ids only the first action runs.

Built-in actions and factories​

ExportWhat it does
recordsDrilldownAction"Drill Down" (id salesloft-drilldown-action): the records behind the clicked point, in a RecordsTable
kpiDrillAction"Drill Down" (id kpi-drill): the records behind a KPI visualization, in a RecordsTable. Scoped to the Liveboard's kpiVizIds (chart type KPI or MUZE_STUDIO, TS_KPI_CHART_TYPES), and left out when it has none
createOpenLiveboardAction({ resolveTarget })Opens the Liveboard or viz resolveTarget(click) returns ({ title, liveboardObjId, vizId?, runtimeFilters?, defaultFilters?, customActions? }) in the modal; null refuses the click. runtimeFilters are fixed and hidden; defaultFilters show in the filter bar and can be edited
createNavigateAction({ resolveRoute })Navigates to the AppRoute resolveRoute(click) returns; null refuses the click

Pick targets and routes from lists declared in code (like LIVEBOARDS) rather than building them from clicked values. toRuntimeFilters / toLiveboardFilters turn the clicked attributes into filters; pass columns to forward only the columns the target has, renamed as needed. mergeLiveboardFilters(base, overrides) adds the overrides to base, replacing its filters on the same column.

The analytics modal​

AdvancedAnalyticsProvider renders a single modal for its subtree, so every Liveboard of a page shares it. An AnalyticsLiveboard rendered without the provider above it creates a modal of its own as a fallback. A Liveboard opened in the modal stacks its own frames on the same modal, with a back button; going back remounts the previous frame. Each frame mounts inside an error boundary: a failing frame shows DefaultErrorState and is reported (component: 'AnalyticsModal') while the Liveboard behind it stays up. useAnalyticsModal() opens it from any component below the provider.

"Drill Down" (recordsDrilldownAction)​

On click, recordsDrilldownAction (through resolveRecordsQuery):

  1. Reads the clicked measures and attributes, plus the answer's filters (embedAnswerData.filterGroups), from the payload.
  2. Maps the ThoughtSpot model to a Cube view: modelViews (read from the Liveboard's metadata) first, TS_MODEL_SL_VIEW as a fallback, and each column to <view>.<column>.
  3. Loads the Cube meta (once per board, retried after a failure) and collects the drillMembers declared by each clicked measure.
  4. Adds the board's runtime filters (including the User filter), which ThoughtSpot leaves out of the payload, as <view>.<columnName> filters (IN/EQ → equals, NE → notEquals). Columns the view lacks, other operators, and members already filtered by the click or the answer are skipped. kpiDrillAction does the same.
  5. Opens a RecordsTable with dimensions: drillMembers, a measureFilter per clicked measure, the equality filters, and date filters as timeDimensions. A clicked-point filter wins over an answer filter on the same member. Above the table, a card lists every attribute of the clicked point (selected or not) with its value from the payload, a time-bucketed one as the first day of its bucket.

A click that can't be mapped (no clicked point or measure, formula columns, unmapped model, unknown view/measure/member, no drill members, Cube meta unavailable) leaves the modal closed and is refused with that reason.

Drilldown summaries​

A clicked measure that declares meta.drilldownSummary opens metric cards above its records (SummaryDrilldown) instead of a plain RecordsTable:

{
name: `count`,
alias: `sent`,
title: `Emails Sent`,
meta: {
drilldownSummary: {
titleDimension: `account_name`,
heading: `Email activity`,
measures: [
`sent`,
{ measure: `bounced`, percentage: `bounced_rate` },
{ measure: `opened`, percentage: `opened_rate` },
{ measure: `replied`, percentage: `replied_rate`, avgPerDay: `replied_per_day` },
],
},
},
}
FieldRequiredMeaning
measuresyes, unless dimensions is setCards, in order: a view measure name, or { measure, percentage?, avg?, avgPerDay? }. Include the clicked measure: its card is selected first and lists the original records.
dimensionsnoView dimensions shown together in one card above the measure cards, as label and value (e.g. account_name, account_owner, last_contacted).
titleDimensionnoView dimension whose value prefixes the modal title (Acme - Emails Sent (7)), when the click restricts it to a single value. Otherwise the title stays Drilldown - ….
headingnoHeading above the cards, shown as written (not localized).

The snake-case spellings title_dimension and avg_per_day are also accepted. Under a card's total, each declared detail measure shows its value: percentage as a percent (the measure holds a 0–1 fraction, e.g. 0.245 → 24.5%), avg as Avg 2.3, and avgPerDay as 1.4 / day, each with at most one decimal. A detail whose value is empty is left out.

A dimension can declare meta.drilldownSummary too. "Drill Down" on a table cell of that dimension (a click with no selected measure) opens its summary, scoped to the clicked value, the answer filters and the runtime filters, titled <titleDimension value> - <heading> (e.g. Acme - Account). No records list below until a measure card is selected. A dimension with no drilldownSummary is refused as before.

On a measure click, with or without a summary, each attribute of the clicked point (selected or not) whose dimension declares a drilldownSummary with dimensions also shows that summary's heading and dimensions card, above the measure's cards, or above the clicked-attributes card and the records when the measure declares no summary (e.g. the row's Account card above Activity). Its measure cards are not shown there. An invalid one is refused, and the rest of the drilldown still opens.

The dimensions card is display only. It queries the dimensions over the click's filters, without the date range, and formats each value by its Cube type: dates as Oct 1, 2026, numbers with grouping (except a dimension whose meta.type is id, shown as is, e.g. 28473), and an empty value as -. A dimension whose meta.link names another member of the view (e.g. account_name → account_url) links its value to that member's url, queried alongside but not shown; with an empty url it is plain text. It only shows when that matches exactly one record (e.g. the click is on one account); otherwise it is left out. A summary with dimensions and no measures shows only that card above the records.

The cards query the summary measures over the click's filters and date range. Clicking a card whose measure declares drillMembers swaps the table below to those records (through buildMeasureDrillQuery); other cards are not clickable. When the card's measure declares a drilldownSummary of its own (with different card measures), the click stacks a new frame with that summary instead, and the modal's back arrow returns to the previous one. Members are named relative to the view. A summary listing members absent from the view is refused with INVALID_SUMMARY, and the records open in a plain RecordsTable.

Records table cells​

RecordsTable turns the Cube annotation of each queried member into a column through mapColumnsToTableFormat (utils/tableUtils):

Member annotationCell
meta.hidden: trueColumn left out
format: 'link'The column's short title, linking to the cell's url
meta.type: 'id'The value as is, without number grouping
meta.link naming a queried member of the viewThe value, linking to that member's url in the same row; plain text when the url is empty
A measure declaring drillMembersClickable, drilling into those records

When several apply, meta.link wins over meta.type: 'id', which wins over format: 'link'. An empty value shows as -. Relative urls are resolved against the app's origin (toHostUrl). Every link opens in a new tab through NewTabLink, followed by an open-in-new-tab icon that is hidden from assistive technology, so the link's accessible name stays its text. The dimensions card links its values with the same NewTabLink.

"Drill Down" on a KPI (kpiDrillAction)​

On a KPI visualization, kpiDrillAction (through resolveKpiDrillQuery) reads the KPI's measure columns and the answer's filters from embedAnswerData, maps them to Cube as above, and opens a RecordsTable with the meta.drilldown declared by each measure:

measures:
- name: count
meta:
drilldown:
dimensions: [id, owner]
measures: [duration]
filters:
- { member: duration, operator: measureFilter }

Members are named relative to the view. A KPI whose measures declare no meta.drilldown, or that lists members absent from the view, is refused with that reason.

In the records table, members with format: "link" (for example recordings.url) render as a link labelled with the member's shortTitle that opens the row's URL in a new tab; an empty value shows -.

Members with meta.hidden: true are queried but not shown as columns. A member with meta.link names the member (relative to the view, or fully qualified) holding its URL: the cell value renders as a link to that URL in a new tab, and as plain text when the URL is empty or the linked member is not queried.

A measure in the records table that declares drillMembers is clickable. The click (through buildMeasureDrillQuery) stacks a RecordsTable frame, with a back button, listing that measure's drillMembers with a measureFilter on it, the clicked row's non-time dimension values (notSet when empty), and the parent query's filters (without its measureFilters) and time dimensions.


Controlling visible actions​

ThoughtSpot embeds expose a large set of toolbar and context-menu actions. You control which ones appear with visibleActions (allowlist) or hiddenActions (denylist) — pick one, never both.

Important: visibleActions / hiddenActions affect the UI only. ThoughtSpot RBAC and row-level security (ts_var()) remain the authoritative authorization layer.

GENERAL_VISIBLE_ACTIONS — the read-only consumer preset​

Enables AI highlights, Spotter/Ask AI, scheduling, all download formats, filtering and the personalized views dropdown — hides everything else (including native drill-down). The Liveboard's custom actions are added to it automatically:

import { AnalyticsLiveboard, GENERAL_VISIBLE_ACTIONS } from '@shared/advanced-analytics'

<AnalyticsLiveboard
viewKey={LIVEBOARDS.myPage.viewKey}
liveboardObjId="<obj-id>"
visibleActions={GENERAL_VISIBLE_ACTIONS}
/>

Extending the preset​

import { Action } from '@thoughtspot/visual-embed-sdk'

<AnalyticsLiveboard
viewKey={LIVEBOARDS.myPage.viewKey}
liveboardObjId="<obj-id>"
visibleActions={[
...GENERAL_VISIBLE_ACTIONS,
Action.Edit,
Action.ShareViz,
]}
/>

Full Action reference: https://developers.thoughtspot.com/docs/embed-actions


useThoughtSpotAuth​

Use the hook directly only when you need full control over the auth lifecycle outside of a Liveboard context (e.g. to guard other UI):

import { useThoughtSpotAuth } from '@shared/advanced-analytics'

export function CustomPage() {
const { status, error } = useThoughtSpotAuth()

if (status === 'loading') return <MyCustomSkeleton />
if (status === 'error') return <MyCustomError error={error} />

return <MyCustomEmbed />
}

Must be rendered inside AdvancedAnalyticsProvider.


Tests​

cd infrastructure/jest && JEST_RUN_ALL_TESTS=1 npx jest --no-coverage shared/advanced-analytics
SpecCovers
StandaloneLiveboard.spec.tsxAuth lifecycle (idle/loading/error), custom fallbacks, auth error reported once, embed errors (pre- and post-render, sanitized Sentry payload), lifecycle callbacks (onLoad, onLiveboardRendered, onFilterChanged), filter sync wiring (including defaultFilters kept off the SDK), skeleton while URL filters apply, remount on embedKey, runtimeFilters passthrough, Liveboard change reset, locale resolution, and obj_id resolution (waits for auth, skeleton while resolving, resolved GUID embedded, resolution and auth errors reported once, obj_id in error reports), custom actions wiring (definitions and handler on the embed, defaults, ids merged into visibleActions, modal provider), and embed size
components.spec.tsxDefaultErrorState: generic copy by default and for reason="error", maintenance copy for reason="maintenance"
AnalyticsLiveboard.spec.tsxThe views layer: clears tsFilters on mount, provider options, views from Salesloft (default and given), toolbar and embed wiring, frame override
LiveboardToolbar.spec.tsxRenders its actions in order
ThoughtSpotAuthContext.spec.tsxSDK init once, locale resolution and fallback, failures before and after login (self-healing vs fatal), recovery
thoughtspotAuthService.spec.tsToken request, response mapping, empty/invalid token, request failures
liveboardService.spec.tsmetadata/search request with and without include_details (token, body, not-ok hook), exact obj_id match, no match, request and token failures
useLiveboardQuery.spec.tsxDetails resolved and cached under the obj_id query key, errors, disabled until auth is ready; without waiting for the details (GUID with empty details, both requested at once, details filling in, details failing alone, GUID failing); the stored Liveboard (written on success, answered first, trusted while fresh, asked again in the background when stale, not shared between users); loadDetails (known details, stale details, joining the request in flight)
usePrefetchLiveboard.spec.tsxDetails and GUID requested once auth is ready, only the details when the embed waits for them, nothing before auth, once per mount
liveboardCache.spec.tsWrite and read back, scope by cluster, user and obj_id, maximum age, entries that are not valid (another version, bad shape, not JSON), storage that throws
needsLiveboardDetails.spec.tsOnly a scope function needs the details
useLiveboardFilterUrlSync.spec.tsURL → Liveboard (UpdateFilters on the UpdateFilters subscription, sent once, "No answer found" treated as applied, safety net with warning, legacy runtime filters ignored), readiness signals (subscription and LiveboardRendered in either order, applied once, listener removed on unmount), navigation while mounted (reload when a column is dropped, in-place update otherwise, only URL columns sent, no reload for own writes, back to the previous state after a user change, a navigation between two empty states still reports, a user read waiting for its debounce is dropped when a navigation applies a state), skeleton hold (release on success, failure and timeout; also while a navigation is applied with a listener, released at the board's report, at once when already readable, on timeout and on failure, and never without a listener), Liveboard → URL (debounce, push for user changes and replace for the hook's own writes, no-op when unchanged, custom param, disabled, state too large), default filters (sync off, URL without state, URL wins, explicit empty state, navigation, applied once, latest defaults on a new board, invalid defaults, skeleton), liveboardId change, stale responses, sanitized logs, cleanup on unmount
filterSync.spec.tsURL read/write (including the write-side length limit, missing vs explicitly empty state, keepEmpty), state resolution (URL vs defaults), default filter validation, validation of untrusted input (types, applicability, length and count limits, legacy runtime filters), UpdateFilters payload, GetFilters response parsing (value and date filters, runtime filters ignored, malformed entries)
embedError.spec.tsAllowlisted error report for Error instances, SDK payloads and trigger rejections; truncation and caps; duplicate signature; Sentry exception titles
drilldown.spec.tsEpoch → date range (period filters end the day before highEpoch, custom ranges on it), click → Cube members (clicked and answer filters, negation, date filters, precedence, every failure reason), drill member resolution, query building, measure drill query, DrilldownClick normalization
useLiveboardCustomActions.spec.tsxSDK definitions (plain fields, defaults, scope, scope resolvers), duplicate ids, stable handler, dispatch by id, loadDetails (details used on click, waiting for them, failure reported), errors and refusals reported once, Cube meta cached and retried, navigate (filters in the URL, unsafe routes refused)
recordsDrilldownAction.spec.tsxRecords query opened in the modal, modelViews and TS_MODEL_SL_VIEW lookup, refusals (unmapped model, no drill members, meta unavailable); kpiDrillAction scope, meta.drilldown query and refusals
factories.spec.tsxcreateOpenLiveboardAction (modal-sized embed, runtime and default filters, refusal without target) and createNavigateAction
AnalyticsModalProvider.spec.tsxOpen, close, stacked frames and back, stable API, failing frame contained and reported, nested providers reused
AdvancedAnalyticsProvider.spec.tsxChildren rendered inside the modal provider, inside auth
RecordsTable.spec.tsxRows and first-page query, loading state, link columns, meta.hidden and meta.link, measure drilldown in a stacked frame
clickFilters.spec.ts / navigation.spec.tsClick → runtime / Liveboard filters (column map, primitive values only), filter merging by column; in-app path validation and URL filters
dateFilter.spec.tsDefault and partial ranges, fiscal tokens, resolution to the first and last second of the days in local time (relative, calendar year and quarter, ISO, fiscal with the tenant's periods), ranges that cannot be read or end before they start, the BW_INC runtime filter
useDateRuntimeFilters.spec.tsThe filters the embed opens with (none while loading or unavailable), no event for the opening range, a new range sent with every filter and without changing the embed's filters, once per range, waiting for the render, failure reported and the embed rebuilt, "No answer found" counted as applied
useDateRangeFilter.spec.tsNo filter without a date column, default and view ranges, a calendar range not waiting for the fiscal calendar, a fiscal range waiting then resolving, unavailable when the calendar cannot be loaded, picking a fiscal range after the calendar was fetched
DateRangeFilter.spec.tsxOpens on the last 7 days, reads and writes the range through the saved views store, offers the fiscal options
hostParams.spec.tsHost params read from name[] and name, written with every other param kept, removed when unset, round trip, order-insensitive signature, store values as strings
useHostParamsUrlSync.spec.tsStore → URL (replace on open, push after, nothing when equal, other params kept, removal), URL → store (back or a link, nothing on open, no echo, params it does not own ignored)
viewParams.spec.tsThe date range and selections are saved with a view; other params are dropped

Saved views specs:

SpecCovers
useLiveboardViewBridge.spec.tsEvery view opens through its own filters, baseline and modified state (opening state, echo, change, going back, state reported before the baseline, a saved view compared with what the board reports), Reset and view switches open the view in place and wait for the next report as the new baseline, a URL that already held the view keeps the last state read as the baseline, saving in place and as a new view
standardViews.spec.tsOne view per definition, ids equal to the position and unchanged when a definition is appended, the first definition (id 0) being the one the engine opens by default, all flagged default, filters stored as the string the Liveboard reads
viewSections.spec.tsA view saved as default stays out of "Views from Salesloft"; views with no default flag are flagged; order and fields kept; no mutation; a list that has not loaded yet
filtersParam.spec.tsSets, replaces and removes the tsFilters param, keeps other params, no history entry when nothing changes, returns whether the URL changed

useLiveboardFilterUrlSync.spec.ts also covers that a change to a param it does not own does not re-apply its filters, and that a host param written while a read is in flight is kept. It also covers the reported filter state (baseline, user and navigation sources, the wait for every applied filter and its values, discarding a read already in flight when a state is applied, closing it at the next change event, apply failures, no reads without a listener or with the sync off), and StandaloneLiveboard.spec.tsx that onFilterStateChange reaches the hook and stays off the SDK embed. AnalyticsLiveboard.spec.tsx also covers the date range (picker and filter only with a dateColumn, the column not passed on, a range change marking the view as modified) and keeping the host params in step with the URL. It covers the views layer: it leaves the tsFilters param alone on mount, stores the views under the viewKey without rewriting the URL, builds the views from Salesloft (one "Default" view when none are given or the array is empty), and wires the toolbar and the embed to the bridge.

useLiveboardFilterUrlSync.spec.ts uses fake timers without faking setImmediate / nextTick, because the shared jest setup replaces global.Promise with the promise polyfill, which runs on them.


Pending items​

ItemOwnerNotes
CORS for rhapsody origins on the ThoughtSpot clusterThoughtSpot adminRequired by liveboardObjId: the browser calls metadata/search directly
Maintenance check (system/banner), removedAnalytics Aces / ThoughtSpot adminRemoved on 2026-11-03: from a browser at https://localhost.qasalesloft.com, salesloft-dev answered system/banner with the HTML "Idle Screen" page and tenant-state: Starting, with no CORS headers, while /system and /auth/session/user returned access-control-allow-origin for that origin. The docs do not say whether system/banner sends CORS headers. Before bringing it back, confirm the call from the browser on every environment, the under_maintenance: true answer and what it returns while the cluster is idle
Liveboards for production pagesAnalytics AcesThe example pages reference demo Liveboards by obj_id
Tab GUIDs (activeTabId) per environmentAnalytics AcesTab GUIDs also differ between environments and are not resolved from obj_ids
Model → Cube view mapping for drilldownsAnalytics Aces / BackendTS_MODEL_SL_VIEW only maps MeetingsModel; resolving per column needs an analytics-api proxy for ThoughtSpot's metadata API
THOUGHTSPOT_URL in deploy manifestsInfraAdd to dev.env, ci.env, and prod manifests
analytics-api PR #2030 mergedBackendUnblocks end-to-end testing
Compare each tab's numbers with its Analytics Hub report for the same range and usersAnalytics AcesOnly that the tiles load and respond to the range was checked, not that the figures match
Translate saved views that keep a date filter in tsFilters, and the views users saved in the Analytics Hub reportsAnalytics AcesViews saved before the date range filter keep their date in tsFilters, and the reports' views use from, to, group_id and account_id without tsFilters; both are ignored by the picker today
Does UpdateRuntimeFilters add to or replace the filters the embed opened with?Analytics Aces / ThoughtSpotTest on a Liveboard where the user filter changes the numbers; until then every event sends the full list
Time zone of the day boundariesAnalytics AcesDays use the browser's zone; confirm against the organization's zone in ThoughtSpot and the Cube view
Weekends off and comparison periodsProductNot expressible as a runtime filter on a date column
Saved views gate on page loadAnalytics AcesThe embed waits for SavedViewsProvider, the longest wait before the iframe exists on a full page load. See the saved views gate
A new Liveboard added to LIVEBOARDS needs its view key in Melody's listBackendThe nine current keys accepted a create on 2026-11-06 (QA). Create returns 422 for a view outside the list: see Backend
Default view stored in the browserAnalytics Aces / ProductDecide whether a per-browser default is enough or Melody's is_default should be used
ThoughtSpot access groups and RBACThoughtSpot / ProductConsumption vs authoring capabilities
Tenant RLS (ts_var) configurationThoughtSpot / DataTenant isolation validation required before production

Environment variable​

Registered in infrastructure/server/src/envs.ts:

THOUGHTSPOT_URL: {
owner: '@Salesloft/analytics-aces',
fallback: 'https://salesloft-clari.thoughtspot.cloud',
},

Backend contract​

PropertyValue
Serviceanalytics-api
EndpointGET /v2/thoughtspot/embed_token
AuthSalesloft Bearer token (resolved server-side)
Response{ token: string, expires_in: number }
Referenceanalytics-api PR #2030

Saved views of a Liveboard use a third service:

PropertyValue
ServiceMelody public API (${API_GATEWAY_URL}/v2), called by @shared/saved-views
EndpointsGET, POST saved_list_views.json, PUT, DELETE saved_list_views/:id.json, no new ones
viewOne advanced_analytics_* key per Liveboard (LIVEBOARDS.<name>.viewKey); all nine accepted a create on 2026-11-06 (QA)
Payloadview_params: { tsFilters: "<JSON string>", view_created_by: <user id> }

References​

TopicURL
Visual Embed SDK — getting startedhttps://developers.thoughtspot.com/docs/tsembed
Trusted Authenticationhttps://developers.thoughtspot.com/docs/trusted-auth-secret-key
Embed auth overviewhttps://developers.thoughtspot.com/docs/embed-auth
React embed componentshttps://developers.thoughtspot.com/docs/react-app-embed
Liveboard embedhttps://developers.thoughtspot.com/docs/embed-liveboard
Supported locales (locale prop)https://developers.thoughtspot.com/docs/set-locale
Runtime filtershttps://developers.thoughtspot.com/docs/runtime-filters
Actions (visibleActions / hiddenActions)https://developers.thoughtspot.com/docs/embed-actions
EmbedEvent referencehttps://developers.thoughtspot.com/docs/embed-events
SDK changeloghttps://developers.thoughtspot.com/docs/changelog
Design system Boxhttps://salesloft.design/docs/components/box
Design system Texthttps://salesloft.design/docs/components/text