You paste one script tag into your HTML and your landing page gains waitlist signups, privacy-friendly analytics, referral tracking, forms, and AI chat. It works across plain HTML files, Astro sites, Next.js apps, and Lovable or Bolt projects without changing how any of those tools work. The reason is a specific combination of architectural choices in os.js that are worth understanding before you deploy it to production.
os.js is a ~13KB gzipped IIFE bundle that reads your project key from a data-project attribute, fetches your dashboard config once, renders all widgets in Shadow DOM (so CSS cannot leak), exposes a window.OperatorStack SDK object, and batches analytics events every 5 seconds plus on page unload. Nothing blocks rendering.
The script tag
The full embed tag:
<script src="https://operatorstack.dev/os.js" data-project="pk_your_project_key" defer></script>
Three things to notice:
defermeans the script executes after the HTML is parsed, never blocking the first paint.data-projectis the only required attribute. The embed script reads it withgetAttribute('data-project')at runtime.- The URL has no version number. OperatorStack updates
os.jsin place, so you never need to change the tag to get fixes or new features.
Optional attributes the script reads: data-api (point to a self-hosted API), data-dev="true" (enable debug logging), data-exclude (comma-separated path prefixes to skip analytics on).
What happens on load
The bundle is an IIFE (immediately invoked function expression) built with Vite. When it executes:
getProjectKey()reads thedata-projectattribute from the script tag.- If no key is found, initialization stops silently. No errors thrown, no console spam.
- The script calls
getConfig(projectKey), which checks localStorage for a cached copy (5-minute TTL). On a cold load it makes one API request to/v1/projects/{key}/config. - Config returns the settings for every enabled tool: waitlist styling, form definitions, chat configuration.
- The script scans the DOM for
[data-os-widget]and[data-os-form]containers and renders a widget into each one. - It calls
markReady(projectKey), which resolves the internalreadyPromiseand makeswindow.OperatorStack.readyresolve.
The single API call on cold load is 1-2KB of JSON. Subsequent page loads within 5 minutes use the localStorage cache and make zero network requests on init.
Shadow DOM isolation
Every widget renders into a Shadow DOM root:
// from web/embed/widgets/base.ts
function createWidgetRoot(hostElement, style) {
const shadow = hostElement.attachShadow({ mode: "open" });
const styleEl = document.createElement("style");
styleEl.textContent = widgetStyles; // bundled CSS as a string
shadow.appendChild(styleEl);
return shadow;
}
attachShadow({ mode: "open" }) creates a hard CSS boundary. Your page's stylesheet cannot reach inside the shadow root, and the widget's CSS cannot leak out. If your landing page uses Tailwind, Bootstrap, or a CSS reset, the widget renders exactly the same.
The chat bubble uses the same pattern, calling host.attachShadow({ mode: "open" }) directly before mounting the chat interface.
You can customize widget appearance with two CSS custom properties set on the host element's style: --os-accent (button and link color) and --os-border-radius. These are read from the style object in your dashboard config and applied before the shadow root is closed.
The SDK surface
When the script loads, it sets window.OperatorStack to an object with these methods:
| Method | Returns | Purpose |
|---|---|---|
joinWaitlist(params) | Promise<{referral_code, referral_link}> | Sign up the current visitor |
submitForm(formKey, data) | Promise<void> | Submit a custom form by its frm_... key |
trackEvent(name, metadata?) | void | Push a custom analytics event |
sendContactMessage(params) | Promise<void> | Send a contact/support message |
getShareLinks(referralCode, options?) | {twitter, linkedin, email, whatsapp, copy} | Get ready-to-paste social share URLs |
getReferralLink() | {referral_code, referral_link} | null | Get the current visitor's referral link if signed up |
isSignedUp() | boolean | Check whether the current visitor has signed up |
getVisitorId() | string | null | Get the persistent analytics visitor ID |
showChat() | Promise<void> | Show the chat bubble |
hideChat() | Promise<void> | Hide and remove the chat bubble |
ready | Promise<void> | Resolves when init is complete |
All async methods internally await the readyPromise, so you can call them immediately without polling.
Practical SDK examples
Skip the widget HTML entirely and wire up your own button:
<button id="join-btn">Join the waitlist</button>
<script>
document.getElementById("join-btn").addEventListener("click", async () => {
const email = document.getElementById("email-input").value;
const result = await window.OperatorStack.joinWaitlist({ email });
// result.referral_link is ready to display
console.log("Referral link:", result.referral_link);
});
</script>
Track a custom event when a user hits a pricing page:
window.OperatorStack.trackEvent("viewed_pricing", { plan: "pro" });
Show share links after signup:
const result = await window.OperatorStack.joinWaitlist({ email });
const links = window.OperatorStack.getShareLinks(result.referral_code);
// links.twitter, links.copy, links.whatsapp, etc.
getShareLinks is synchronous. It returns immediately because no network call is needed. Each URL already encodes the referral code so attribution is automatic.
Event batching and performance
Analytics events are not sent one by one. The embed script holds events in an in-memory queue and flushes in two ways:
- Timer flush: every 5 seconds if the queue is non-empty.
- Page unload:
sendBeaconfires the queue when the tab closes or navigates away.sendBeaconworks even if the page is unloading, and browsers do not cancel it.
The first batch fires immediately via setTimeout(0) to capture the page-view event from the initial load without adding a 5-second delay.
If you are testing and want to see events appear in your dashboard immediately, do not wait 5 seconds. Open your browser devtools Network tab and look for the /v1/projects/{key}/events/batch request. It fires within milliseconds of the first setTimeout(0) tick.
Widget containers
To render a widget at a specific location on your page, place a container element with a data-os-widget attribute:
<!-- Waitlist widget -->
<div data-os-widget="waitlist"></div>
<!-- Contact form -->
<div data-os-widget="contact"></div>
<!-- Custom form by key -->
<div data-os-form="frm_abc123"></div>
The embed script scans for these on load and renders the corresponding widget into each container using createWidgetRoot. You can have multiple containers on the same page. Each gets its own Shadow DOM root.
If you do not place any containers, the SDK methods are still available. The chat bubble, if enabled in your dashboard, renders itself into a fixed-position element appended to document.body.
What you are actually deploying
When you paste the script tag, you are deploying a single 13KB gzip request that replaces five or more separate integrations. There is one config API call on cold load (cached for 5 minutes), no render-blocking JavaScript, no shared global CSS, and a clean SDK on window.OperatorStack you can call from anywhere on the page. The architecture is deliberately boring so it works on Carrd pages, Webflow sites, and Next.js App Router layouts without modification.
Frequently Asked Questions
Does the script tag block page rendering?
No. The script tag includes the async attribute, so it loads in parallel with your HTML. The IIFE executes after the DOM is ready, and analytics events are batched rather than sent on every interaction. Pages load the same with or without the script.
Can the embed widget CSS break my page styles?
No. Every widget renders inside a Shadow DOM root created with attachShadow({ mode: 'open' }). Shadow DOM is a hard CSS boundary. Styles inside the shadow root cannot leak out, and page styles cannot accidentally override widget styles.
When is window.OperatorStack available?
The window.OperatorStack object is set synchronously when the script tag executes. All async methods internally await a ready Promise, so you can call them before init completes. Await window.OperatorStack.ready if you need to confirm initialization is fully done.
How does referral tracking work without any code from me?
The embed script reads the ?ref= query parameter from the URL on load and stores it in sessionStorage. When joinWaitlist() is called, the stored ref code is included automatically. You do not write any referral-handling code.
Why does the script tag use data-project instead of data-project-key?
The attribute is data-project. The embed script reads it via getAttribute('data-project') at load time. This is shorter and consistent with the other optional data attributes (data-api, data-dev, data-exclude).