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:

  • defer means the script executes after the HTML is parsed, never blocking the first paint.
  • data-project is the only required attribute. The embed script reads it with getAttribute('data-project') at runtime.
  • The URL has no version number. OperatorStack updates os.js in 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:

  1. getProjectKey() reads the data-project attribute from the script tag.
  2. If no key is found, initialization stops silently. No errors thrown, no console spam.
  3. 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.
  4. Config returns the settings for every enabled tool: waitlist styling, form definitions, chat configuration.
  5. The script scans the DOM for [data-os-widget] and [data-os-form] containers and renders a widget into each one.
  6. It calls markReady(projectKey), which resolves the internal readyPromise and makes window.OperatorStack.ready resolve.

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:

MethodReturnsPurpose
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?)voidPush 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} | nullGet the current visitor's referral link if signed up
isSignedUp()booleanCheck whether the current visitor has signed up
getVisitorId()string | nullGet the persistent analytics visitor ID
showChat()Promise<void>Show the chat bubble
hideChat()Promise<void>Hide and remove the chat bubble
readyPromise<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: sendBeacon fires the queue when the tab closes or navigates away. sendBeacon works 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).