Skip to main content

Toolbar and Settings Extensions

The chart toolbar and the settings dialog both accept content the SDK knows nothing about. You get a DOM element and render whatever belongs there: a button, an input, a switch, a segmented control, a panel trigger that opens your own screener somewhere else entirely in your application.

Extensions are plain DOM. No framework is required and none is assumed — the same extension works from React, Vue, Svelte, Angular, or hand-written DOM.

This is a different mechanism from a registered action. An action is a label, an icon, and a handler, and the SDK draws the button. An extension is yours from the container inwards.

TradeScript top toolbar strip showing symbol search, interval selector, compare, storage, indicators, chart type, range, undo, timezone, screenshot, and fullscreen controls in one row The composable cluster is the right-hand run of controls, from the indicator picker to the fullscreen button. Your own items take their place in that order.

The extension contract

Every extension point takes the same object:

interface TradeScriptDomExtension<Context> {
mount(container: HTMLElement, context: Context): {
update(context: Context): void;
destroy(): void;
};
}

mount runs once with an empty element you own. Return a handle. update runs when the context changes. destroy runs when the chart tears down, and must release everything.

The SDK never reads or writes inside your container.

Plain DOM

const symbolBadge = {
mount(container, context) {
const badge = document.createElement('span');
badge.textContent = context.symbol?.ticker ?? '';
container.append(badge);
return {
update: (next) => {
badge.textContent = next.symbol?.ticker ?? '';
},
destroy: () => badge.remove(),
};
},
};

React

Mount a root inside the container and unmount it on destroy.

import { createRoot } from 'react-dom/client';

const symbolBadge = {
mount(container, context) {
const root = createRoot(container);
root.render(<SymbolBadge context={context} />);
return {
update: (next) => root.render(<SymbolBadge context={next} />),
destroy: () => root.unmount(),
};
},
};

Vue

import { createApp, reactive } from 'vue';

const symbolBadge = {
mount(container, context) {
const state = reactive({ context });
const app = createApp(SymbolBadge, { state });
app.mount(container);
return {
update: (next) => {
state.context = next;
},
destroy: () => app.unmount(),
};
},
};

Svelte, Angular, Solid and vanilla follow the same three steps: create in mount, re-render in update, tear down in destroy.

A React host can skip the contract entirely and pass render instead, which returns a React node. Where both are set, render wins.

Adding a control to the toolbar

Declare an item with an id, then place that id in the toolbar order.

const chart = sdk.chart.mount({
container: '#chart',
symbol: 'AAPL',
interval: '1D',
datafeed,
toolbar: {
items: [{ id: 'firm-screener', label: 'Screener', extension: screenerExtension }],
actionIds: ['firm-screener', 'toolbar.indicators', 'toolbar.chart-type'],
},
});

actionIds is the left-to-right order for the ids it names, ahead of everything it does not. Ids you leave out keep their default order behind those. Omit actionIds and your item renders after the built-in controls.

Change the composition later through the customization controller:

chart.customization().setToolbar({
items: [{ id: 'firm-screener', label: 'Screener', extension: screenerExtension }],
actionIds: ['firm-screener', 'toolbar.indicators'],
});

Built-in control ids

The composable cluster sits at the right end of the toolbar. These are its ids, in default order, each with the option that shows or hides it:

IdControlGate
toolbar.execution-modeClick-to-add executionexecutionModeVisible
toolbar.optionsOptions panel toggleoptionsVisible
toolbar.paper-tradePaper tradepaperTradeVisible
toolbar.object-inspectorObject inspectorobjectInspectorVisible
toolbar.super-searchChart searchsuperSearchVisible
toolbar.indicatorsIndicator pickerindicatorsVisible
toolbar.chart-typeChart type selectorchartTypeVisible
toolbar.execution-markersExecution marker toggleexecutionMarkersVisible
toolbar.time-rangeTime rangetimeRangeVisible
toolbar.undo-redoUndo and redoundoRedoVisible
toolbar.replayBar replayreplayVisible
toolbar.timezone-menuTimezone menutimezoneMenuVisible
toolbar.screenshotScreenshotscreenshotButtonVisible
toolbar.fullscreenFullscreenfullscreenButtonVisible

Read the same list at runtime rather than copying it:

import { listBuiltInControlsForSurface } from '@tradescript/pro/sdk';

listBuiltInControlsForSurface('period-bar').map((control) => control.id);

The symbol search, the interval selector, and the time-frame shortcuts sit in the toolbar's left and centre regions. Those are layout rather than a list of peers, so they carry ids and visibility gates but do not take part in ordering.

Remove any control by id:

toolbar: { hiddenActionIds: ['toolbar.paper-trade', 'toolbar.options'] }

Hiding wins over ordering: an id named in both lists is hidden.

Replacing a built-in control

controlOverrides swaps what a control renders while keeping its position, its order, and its gate.

toolbar: {
controlOverrides: {
'toolbar.screenshot': { extension: firmExportButton },
},
}

An override extension also receives defaultContent, a handle to the SDK's own control. Mount it to keep the built-in inside your own chrome; ignore it to drop the built-in entirely.

const firmExportButton = {
mount(container, context) {
const wrapper = document.createElement('span');
wrapper.className = 'firm-export';
container.append(wrapper);

const builtIn = context.defaultContent.mount(wrapper);

return {
update: () => {},
destroy: () => {
builtIn.destroy();
wrapper.remove();
},
};
},
};

An override respects the built-in's visibility gate. Set respectVisibility: false to render regardless.

What the SDK passes you

interface ChartToolbarItemContext {
id: string;
display: 'full' | 'icon' | 'overflow';
symbol?: SdkSymbolInfo;
interval?: ChartInterval;
chart?: ChartApi;
}

update runs when any of these change. chart is the same handle the rest of your integration holds, so a control can read chart state and drive the chart directly.

Passing your own values in

An extension is an ordinary object, so close over whatever you like. Your data never travels through the SDK.

function createScreenerExtension({ apiClient, onSelect }) {
return {
mount(container, context) {
// apiClient and onSelect are yours, captured here.
},
};
}

toolbar: {
items: [{
id: 'firm-screener',
label: 'Screener',
extension: createScreenerExtension({
apiClient,
onSelect: (ticker) => chart.setSymbol(ticker),
}),
}],
}

To push a value in after mounting, keep your own reference and re-render from it. The SDK's update carries chart state only.

function createBadge() {
let paint = (_text: string) => {};
return {
extension: {
mount(container: HTMLElement) {
const node = document.createElement('span');
container.append(node);
paint = (text) => {
node.textContent = text;
};
return { update: () => {}, destroy: () => node.remove() };
},
},
setText: (text: string) => paint(text),
};
}

const badge = createBadge();
badge.setText('12 alerts');

Callbacks and events

You own the DOM, so wire listeners directly and remove them in destroy.

mount(container, context) {
const button = document.createElement('button');
const onClick = () => context.chart?.setChartType('line');
button.addEventListener('click', onClick);
container.append(button);
return {
update: () => {},
destroy: () => {
button.removeEventListener('click', onClick);
button.remove();
},
};
}

To react to the chart rather than to the user, subscribe through the chart handle and unsubscribe in destroy.

Async work

mount returns synchronously. Start async work inside it and guard the result against teardown, because a chart can be destroyed before a request settles.

mount(container, context) {
const controller = new AbortController();
const node = document.createElement('div');
node.textContent = 'Loading…';
container.append(node);

loadScreenerResults(context.symbol?.ticker, { signal: controller.signal })
.then((results) => { node.textContent = `${results.length} matches`; })
.catch(() => { node.textContent = 'Unavailable'; });

return {
update: () => {},
destroy: () => {
controller.abort();
node.remove();
},
};
}

Writing to a container after destroy leaks; the SDK does not catch it.

Narrow toolbars

The toolbar measures its own width. When the row runs past its container, controls move into an overflow menu at the end of the bar, starting from the last one, so the control you ordered first stays visible longest.

Each item declares how far it may collapse:

PolicyBehaviour
fullNever collapses, at any width
iconReports display: 'icon' in the compact toolbar, and stays on the bar
overflowAlso moves into the overflow menu when the bar runs out of width
hideDisappears in the compact toolbar

Built-in controls default to overflow. Host items default to icon, because the SDK cannot shrink DOM it does not own — it can only report that space is tight and let you render accordingly.

{ id: 'firm-screener', label: 'Screener', collapse: 'overflow', extension: screenerExtension }

An item that declares overflow needs label; the SDK builds the menu row from it.

mount(container, context) {
const button = document.createElement('button');
const paint = (ctx) => {
button.innerHTML = ctx.display === 'full' ? `${iconSvg}<span>Screener</span>` : iconSvg;
};
paint(context);
container.append(button);
return { update: paint, destroy: () => button.remove() };
}

The mobileToolbar feature still forces the compact bar outright, unchanged. Width measurement covers every other narrow case, including a desktop chart in a tight split.

Adding a settings section

The settings dialog takes host sections on the same contract.

features: {
chartControls: {
settingsCustomSections: [
{
id: 'alert-routing',
title: 'Alert routing',
order: 10,
extension: alertRoutingExtension,
},
],
},
}

The SDK renders the heading and the card. You render the body.

A negative order puts the section ahead of the built-in ones, anything else after them. Sections on the same side render in ascending order.

Section context carries the chart and a way to close the dialog:

interface ChartSettingsSectionContext {
id: string;
chart?: ChartApi;
close: () => void;
}

One policy hides built-in and host sections alike:

features: {
chartControls: {
settingsSections: { trading: false, 'alert-routing': false },
},
}

Host sections render in both presentations — the draggable dialog and the mobile bottom sheet.

Making a control available to agents

Host controls stay invisible to the agent and MCP layer unless you say otherwise. Describe what the control does and an agent can use it:

{
id: 'firm-screener',
label: 'Screener',
extension: screenerExtension,
agentic: {
description: 'Opens the firm screener and returns matching symbols',
read: true,
write: false,
},
}

Omit agentic and the control stays human-only. Adding a control never makes it agent-callable by itself.

Licensing

Extensibility is a separately licensed capability. A deployment whose entitlement does not carry the extensions chart feature is refused when it calls setToolbar with items or controlOverrides, and host sections never render.

Ordering and hiding built-in controls is not gated. actionIds and hiddenActionIds work on every licence; only host-owned content requires the entitlement.

Switch the surfaces off in your own configuration with the extensions feature:

features: {
extensions: {
toolbarItems: true,
controlOverrides: false,
settingsSections: true,
},
}

extensions: false disables all three. Omitted, every surface your licence grants is available.

Ids and collisions

An item id may not match a built-in control id, and a section id may not match a built-in section id. A colliding id would make the built-in unreachable by actionIds, hiddenActionIds, and settingsSections. The SDK keeps the built-in, drops the host declaration, and reports it on the console. The same applies to a duplicate id, and to a declaration carrying neither render nor extension.

Styling

Your content inherits the chart's CSS custom properties, so it can match without importing anything:

.firm-control {
background: var(--ts-chart-surface-bg);
color: var(--ts-chart-surface-text);
border: 1px solid var(--ts-chart-surface-divider);
}

To match a built-in control exactly, use the SDK's button class:

button.className = 'ts-chart-button';

The container is yours; ignore all of it and style however you like.

  • Feature Gates — the control inventory these ids come from.
  • Chrome Slots — restyling the built-in surfaces rather than replacing their contents.
  • Toolbars — the toolbar anatomy these ids attach to.
  • Widget Options — where toolbar and features are passed at construction.