SalesOS

UI extensions

Show your app inside SalesOS — record tabs, side panels, header buttons, list actions, dashboard widgets, full pages and dialogs, on desktop and phones — in a sandboxed frame that talks to SalesOS through @salesos/app-bridge.

An installed app can place its own pages in SalesOS. Each UI extension in the manifest names a slot and an https URL; SalesOS renders that URL in a sandboxed iframe and connects it with @salesos/app-bridge.

SlotWhere it appearsContext
record.tabA tab on Lead, Contact, Account, Deal and custom object record pages (objects). Mounted only while it is the active tab.record
record.sidePanelA panel in the record page's field rail.record
record.headerActionA button in the record page header; opens your page in a dialog.record
list.actionA button on the list page (standard and custom objects); opens your page in a dialog.selection (selected ids, up to 500)
dashboard.widgetA card on the dashboard home.—
app.pageA full page at /dashboard/apps/<slug>/<key>, with an item under Pages in the navigation.—
global.modalNot placed anywhere: a dialog your other extensions open with openModal.modal.params

objects name standard objects (LEAD, CONTACT, ACCOUNT, OPPORTUNITY) or custom objects as custom:<apiName> — an app's own objects use their manifest key. Admins can reorder, hide and position record tabs and side panels with page layouts, and place tabs, side panels, widgets and app pages on page-builder pages (the palette's App extension component).

Phones and form factors

formFactors lists where an extension renders: desktop (768 px wide and up) and/or mobile (narrower). Leave it out to render on both. An extension that doesn't render at the current width is simply not there — no tab, button, nav item or widget — and an app.page opened on the wrong device explains that. On phones record tabs and side panels stack in the page, header buttons wrap under the title, and dialogs (list.action, record.headerAction, global.modal) fill the screen. context.formFactor tells your page which one it is rendering at, and changes (onContextChange) when the window crosses the breakpoint.

{ "key": "score_now", "slot": "record.headerAction", "label": "Score now",
  "url": "https://app.acme.example.com/ext/score", "objects": ["LEAD", "custom:acme_signal"],
  "modalSize": "md", "formFactors": ["desktop", "mobile"] }

modalSize (sm, md, lg, full) sets the dialog width for record.headerAction, list.action and global.modal.

The sandbox

<iframe src="https://app.acme.example.com/ext/lead#salesos-bridge=…"
        sandbox="allow-scripts allow-forms allow-popups"
        referrerpolicy="no-referrer" allow="" title="Acme Enrich: Acme insights">
  • No allow-same-origin: your page runs with an opaque origin. It can't read SalesOS cookies, storage or DOM — and can't use its own cookies or localStorage either, so keep state in memory or on your backend (use the context token to identify the user).
  • No top navigation, no camera, microphone, clipboard or other permissions. Popups are allowed (for OAuth into your own service, or help links).
  • Only https URLs from the manifest are framed. The frame gets no referrer.
  • Your page must allow being framed by SalesOS: if you send Content-Security-Policy: frame-ancestors, include https://www.salesos.org.

SalesOS shows a skeleton until your page connects, and an error with Try again if it doesn't within 15 seconds.

Install the SDK

npm install @salesos/app-bridge
import { connect } from '@salesos/app-bridge';

const bridge = await connect();          // rejects outside SalesOS
const ctx = bridge.getContext();
bridge.autoResize();                      // keep the frame as tall as the page

if (ctx.record) {
  const res = await bridge.api.fetch(`/leads/${ctx.record.id}`);
  if (res.ok) render(res.body);
}

connect() options: allowedHostOrigins (default https://www.salesos.org and https://salesos.org — add your staging host if you have one), handshakeTimeoutMs (10 s) and requestTimeoutMs (10 s; api.fetch 30 s).

API

MethodWhat it does
getContext() / refreshContext()The context: app, slot, record or selection, user, organization, locale, theme, context token.
onContextChange(fn)Called when the user switches theme or language, or the record changes. Returns an unsubscribe function.
resize(height) / autoResize()Size the frame (80–2000 px).
navigate(path)Send the user to a SalesOS route; only /dashboard… paths are accepted.
toast(message, variant?)success, error or info, shown with your app's name.
confirm({ title, message?, confirmLabel?, destructive? })The SalesOS confirm dialog; resolves true when confirmed.
refreshRecord()Reload the record page after your app changed the record.
openModal({ extensionKey, title?, size?, params? })Open one of your app's global.modal extensions in a SalesOS dialog; resolves { result, dismissed } when it closes.
closeModal(result?)From inside a dialog: close it and hand result back to the opener.
api.fetch(path, { method?, query?, body?, idempotencyKey? })Call the SalesOS API — see below.
destroy()Stop the bridge.

Errors reject with AppBridgeError and a code: NOT_EMBEDDED, HOST_NOT_ALLOWED, HANDSHAKE_TIMEOUT, TIMEOUT, RATE_LIMITED, NAVIGATION_BLOCKED, PATH_NOT_ALLOWED, NO_API_ACCESS, INVALID_REQUEST, MODAL_OPEN, MODAL_NOT_FOUND, MODAL_NOT_AVAILABLE, NOT_IN_MODAL.

Dialogs

// In a record tab: let the user pick a contact in your dialog, then use the answer.
const { result, dismissed } = await bridge.openModal({
  extensionKey: 'pick_contact',            // a global.modal extension of the same app
  size: 'md',
  params: { accountId: ctx.record?.id },   // any JSON, up to 32 KB
});
if (!dismissed) await bridge.api.fetch(`/contacts/${(result as { id: string }).id}`);

// In the dialog (the pick_contact page):
const ctx = bridge.getContext();          // ctx.modal.params → { accountId }
await bridge.closeModal({ id: chosen.id }); // up to 32 KB of JSON

The dialog is its own sandboxed frame with its own bridge, token and context; the two frames never talk to each other directly — params and result pass through SalesOS as JSON. An app can open only its own global.modal extensions, one dialog at a time (MODAL_OPEN while one is showing), and the user can always close it (dismissed: true).

api.fetch

const res = await bridge.api.fetch('/leads', { query: { limit: 10, owner: 'me' } });
// { status: 200, ok: true, headers: { 'content-type': 'application/json', … }, body: { data: [...] } }

await bridge.api.fetch(`/leads/${id}`, { method: 'PATCH', body: { rating: 'HOT' }, idempotencyKey: crypto.randomUUID() });

SalesOS makes the request, not your frame. The host mints a short-lived (5-minute) OAuth access token for your app that acts as the viewing user, limited to the scopes the organization granted your app, sends the request without cookies, and returns only the status, a few headers and the body. Your frame never sees the token or the user's session. The user's record visibility, profile and field-level security apply, so your app sees exactly what that person can see, and nothing outside its scopes.

Paths are API paths (/leads/…; a /v1 prefix is optional). Credential and admin APIs (/auth, /api-keys, /oauth, /apps, /admin, …) are never reachable. Each frame may have 8 requests in flight and 300 per minute. Your own backend can call GET /app-runtime/installation the same way to read the installation's settings.

Context

interface BridgeContext {
  app: { slug: string; name: string; installationId: string; extensionKey: string };
  slot: 'record.tab' | 'record.sidePanel' | 'record.headerAction' | 'list.action'
      | 'dashboard.widget' | 'app.page' | 'global.modal';
  record: { type: string; id: string } | null;        // record slots and their dialogs
  selection: { type: string; ids: string[] } | null;  // list actions
  formFactor?: 'desktop' | 'mobile';
  modal?: { params: unknown } | null;                 // inside a dialog opened with openModal
  user: { id: string; name: string | null; locale: string };
  organization: { id: string; name: string | null };
  locale: string;
  theme: { mode: 'light' | 'dark'; tokens: Record<string, string> };
  contextToken: string | null;
}

theme.tokens are the --salesos-* custom properties from @salesos/design-tokens for the active mode; set them on your root element to match SalesOS, and listen with onContextChange for theme switches.

Context token

contextToken is a 5-minute HS256 JWT signed with your app's installation secret for the organization (aud = app:<slug>; claims sub user, org, inst installation, ext extension key, rec record). Send it to your backend to learn who is looking without a login of your own:

import jwt from 'jsonwebtoken';
const { org } = jwt.decode(token) as { org: string };
const claims = jwt.verify(token, installationSecret(APP_SECRET, org), { audience: 'app:acme-enrich', algorithms: ['HS256'] });

rec says which record the page showed; it is not an authorization — read the record through api.fetch (as the user) before acting on it.

Security model

  • Your page runs in a sandboxed frame with an opaque origin: it can't read SalesOS cookies, storage or DOM, and SalesOS never gives it the user's session or an access token.
  • The SDK only connects to an allowed SalesOS origin, and SalesOS only accepts a connection from the frame it created for your extension. Messages after that travel on a private channel that other windows can't observe or inject into.
  • Every request from the frame is validated by SalesOS (paths, sizes, methods) before anything happens; a frame can't widen its scopes or reach another installation. openModal only opens the same installation's global.modal extensions, and closeModal only works in a frame SalesOS shows in a dialog.
  • Revoking the app under Connected apps, uninstalling it, or a marketplace suspension stops token minting immediately.

On this page