[{"data":1,"prerenderedAt":675},["ShallowReactive",2],{"blog-guides/embed-script-architecture":3},{"id":4,"title":5,"body":6,"category":645,"date":646,"dateModified":646,"description":647,"draft":648,"extension":649,"faq":650,"featured":648,"keywords":660,"meta":661,"navigation":662,"ogDescription":663,"ogTitle":664,"path":665,"readTime":666,"schemaOrg":667,"schemaType":668,"seo":669,"sitemap":670,"stem":671,"tags":672,"twitterCard":673,"__hash__":674},"blog/blog/guides/embed-script-architecture.md","Embed Script Architecture: How One Script Tag Powers 10 Tools",{"type":7,"value":8,"toc":632},"minimark",[9,18,34,39,42,52,55,80,95,99,102,157,160,164,167,175,181,188,209,213,219,407,413,418,421,427,430,436,439,445,451,455,458,480,487,500,504,511,517,524,530,534,540,607],[10,11,12,13,17],"p",{},"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 ",[14,15,16],"code",{},"os.js"," that are worth understanding before you deploy it to production.",[19,20,21],"tldr",{},[10,22,23,25,26,29,30,33],{},[14,24,16],{}," is a ~13KB gzipped IIFE bundle that reads your project key from a ",[14,27,28],{},"data-project"," attribute, fetches your dashboard config once, renders all widgets in Shadow DOM (so CSS cannot leak), exposes a ",[14,31,32],{},"window.OperatorStack"," SDK object, and batches analytics events every 5 seconds plus on page unload. Nothing blocks rendering.",[35,36,38],"h2",{"id":37},"the-script-tag","The script tag",[10,40,41],{},"The full embed tag:",[43,44,50],"pre",{"className":45,"code":47,"language":48,"meta":49},[46],"language-html","\u003Cscript src=\"https://operatorstack.dev/os.js\" data-project=\"pk_your_project_key\" defer>\u003C/script>\n","html","",[14,51,47],{"__ignoreMap":49},[10,53,54],{},"Three things to notice:",[56,57,58,65,74],"ul",{},[59,60,61,64],"li",{},[14,62,63],{},"defer"," means the script executes after the HTML is parsed, never blocking the first paint.",[59,66,67,69,70,73],{},[14,68,28],{}," is the only required attribute. The embed script reads it with ",[14,71,72],{},"getAttribute('data-project')"," at runtime.",[59,75,76,77,79],{},"The URL has no version number. OperatorStack updates ",[14,78,16],{}," in place, so you never need to change the tag to get fixes or new features.",[10,81,82,83,86,87,90,91,94],{},"Optional attributes the script reads: ",[14,84,85],{},"data-api"," (point to a self-hosted API), ",[14,88,89],{},"data-dev=\"true\""," (enable debug logging), ",[14,92,93],{},"data-exclude"," (comma-separated path prefixes to skip analytics on).",[35,96,98],{"id":97},"what-happens-on-load","What happens on load",[10,100,101],{},"The bundle is an IIFE (immediately invoked function expression) built with Vite. When it executes:",[103,104,105,114,117,128,131,142],"ol",{},[59,106,107,110,111,113],{},[14,108,109],{},"getProjectKey()"," reads the ",[14,112,28],{}," attribute from the script tag.",[59,115,116],{},"If no key is found, initialization stops silently. No errors thrown, no console spam.",[59,118,119,120,123,124,127],{},"The script calls ",[14,121,122],{},"getConfig(projectKey)",", which checks localStorage for a cached copy (5-minute TTL). On a cold load it makes one API request to ",[14,125,126],{},"/v1/projects/{key}/config",".",[59,129,130],{},"Config returns the settings for every enabled tool: waitlist styling, form definitions, chat configuration.",[59,132,133,134,137,138,141],{},"The script scans the DOM for ",[14,135,136],{},"[data-os-widget]"," and ",[14,139,140],{},"[data-os-form]"," containers and renders a widget into each one.",[59,143,144,145,148,149,152,153,156],{},"It calls ",[14,146,147],{},"markReady(projectKey)",", which resolves the internal ",[14,150,151],{},"readyPromise"," and makes ",[14,154,155],{},"window.OperatorStack.ready"," resolve.",[10,158,159],{},"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.",[35,161,163],{"id":162},"shadow-dom-isolation","Shadow DOM isolation",[10,165,166],{},"Every widget renders into a Shadow DOM root:",[43,168,173],{"className":169,"code":171,"language":172,"meta":49},[170],"language-js","// from web/embed/widgets/base.ts\nfunction createWidgetRoot(hostElement, style) {\n  const shadow = hostElement.attachShadow({ mode: \"open\" });\n\n  const styleEl = document.createElement(\"style\");\n  styleEl.textContent = widgetStyles; // bundled CSS as a string\n  shadow.appendChild(styleEl);\n\n  return shadow;\n}\n","js",[14,174,171],{"__ignoreMap":49},[10,176,177,180],{},[14,178,179],{},"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.",[10,182,183,184,187],{},"The chat bubble uses the same pattern, calling ",[14,185,186],{},"host.attachShadow({ mode: \"open\" })"," directly before mounting the chat interface.",[189,190,191],"info-box",{},[10,192,193,194,197,198,201,202,205,206,208],{},"You can customize widget appearance with two CSS custom properties set on the host element's ",[14,195,196],{},"style",": ",[14,199,200],{},"--os-accent"," (button and link color) and ",[14,203,204],{},"--os-border-radius",". These are read from the ",[14,207,196],{}," object in your dashboard config and applied before the shadow root is closed.",[35,210,212],{"id":211},"the-sdk-surface","The SDK surface",[10,214,215,216,218],{},"When the script loads, it sets ",[14,217,32],{}," to an object with these methods:",[220,221,222,238],"table",{},[223,224,225],"thead",{},[226,227,228,232,235],"tr",{},[229,230,231],"th",{},"Method",[229,233,234],{},"Returns",[229,236,237],{},"Purpose",[239,240,241,257,276,291,305,320,335,350,365,379,393],"tbody",{},[226,242,243,249,254],{},[244,245,246],"td",{},[14,247,248],{},"joinWaitlist(params)",[244,250,251],{},[14,252,253],{},"Promise\u003C{referral_code, referral_link}>",[244,255,256],{},"Sign up the current visitor",[226,258,259,264,269],{},[244,260,261],{},[14,262,263],{},"submitForm(formKey, data)",[244,265,266],{},[14,267,268],{},"Promise\u003Cvoid>",[244,270,271,272,275],{},"Submit a custom form by its ",[14,273,274],{},"frm_..."," key",[226,277,278,283,288],{},[244,279,280],{},[14,281,282],{},"trackEvent(name, metadata?)",[244,284,285],{},[14,286,287],{},"void",[244,289,290],{},"Push a custom analytics event",[226,292,293,298,302],{},[244,294,295],{},[14,296,297],{},"sendContactMessage(params)",[244,299,300],{},[14,301,268],{},[244,303,304],{},"Send a contact/support message",[226,306,307,312,317],{},[244,308,309],{},[14,310,311],{},"getShareLinks(referralCode, options?)",[244,313,314],{},[14,315,316],{},"{twitter, linkedin, email, whatsapp, copy}",[244,318,319],{},"Get ready-to-paste social share URLs",[226,321,322,327,332],{},[244,323,324],{},[14,325,326],{},"getReferralLink()",[244,328,329],{},[14,330,331],{},"{referral_code, referral_link} | null",[244,333,334],{},"Get the current visitor's referral link if signed up",[226,336,337,342,347],{},[244,338,339],{},[14,340,341],{},"isSignedUp()",[244,343,344],{},[14,345,346],{},"boolean",[244,348,349],{},"Check whether the current visitor has signed up",[226,351,352,357,362],{},[244,353,354],{},[14,355,356],{},"getVisitorId()",[244,358,359],{},[14,360,361],{},"string | null",[244,363,364],{},"Get the persistent analytics visitor ID",[226,366,367,372,376],{},[244,368,369],{},[14,370,371],{},"showChat()",[244,373,374],{},[14,375,268],{},[244,377,378],{},"Show the chat bubble",[226,380,381,386,390],{},[244,382,383],{},[14,384,385],{},"hideChat()",[244,387,388],{},[14,389,268],{},[244,391,392],{},"Hide and remove the chat bubble",[226,394,395,400,404],{},[244,396,397],{},[14,398,399],{},"ready",[244,401,402],{},[14,403,268],{},[244,405,406],{},"Resolves when init is complete",[10,408,409,410,412],{},"All async methods internally await the ",[14,411,151],{},", so you can call them immediately without polling.",[414,415,417],"h3",{"id":416},"practical-sdk-examples","Practical SDK examples",[10,419,420],{},"Skip the widget HTML entirely and wire up your own button:",[43,422,425],{"className":423,"code":424,"language":48,"meta":49},[46],"\u003Cbutton id=\"join-btn\">Join the waitlist\u003C/button>\n\n\u003Cscript>\n  document.getElementById(\"join-btn\").addEventListener(\"click\", async () => {\n    const email = document.getElementById(\"email-input\").value;\n    const result = await window.OperatorStack.joinWaitlist({ email });\n    // result.referral_link is ready to display\n    console.log(\"Referral link:\", result.referral_link);\n  });\n\u003C/script>\n",[14,426,424],{"__ignoreMap":49},[10,428,429],{},"Track a custom event when a user hits a pricing page:",[43,431,434],{"className":432,"code":433,"language":172,"meta":49},[170],"window.OperatorStack.trackEvent(\"viewed_pricing\", { plan: \"pro\" });\n",[14,435,433],{"__ignoreMap":49},[10,437,438],{},"Show share links after signup:",[43,440,443],{"className":441,"code":442,"language":172,"meta":49},[170],"const result = await window.OperatorStack.joinWaitlist({ email });\nconst links = window.OperatorStack.getShareLinks(result.referral_code);\n// links.twitter, links.copy, links.whatsapp, etc.\n",[14,444,442],{"__ignoreMap":49},[10,446,447,450],{},[14,448,449],{},"getShareLinks"," is synchronous. It returns immediately because no network call is needed. Each URL already encodes the referral code so attribution is automatic.",[35,452,454],{"id":453},"event-batching-and-performance","Event batching and performance",[10,456,457],{},"Analytics events are not sent one by one. The embed script holds events in an in-memory queue and flushes in two ways:",[56,459,460,467],{},[59,461,462,466],{},[463,464,465],"strong",{},"Timer flush:"," every 5 seconds if the queue is non-empty.",[59,468,469,472,473,476,477,479],{},[463,470,471],{},"Page unload:"," ",[14,474,475],{},"sendBeacon"," fires the queue when the tab closes or navigates away. ",[14,478,475],{}," works even if the page is unloading, and browsers do not cancel it.",[10,481,482,483,486],{},"The first batch fires immediately via ",[14,484,485],{},"setTimeout(0)"," to capture the page-view event from the initial load without adding a 5-second delay.",[488,489,490],"tip-box",{},[10,491,492,493,496,497,499],{},"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 ",[14,494,495],{},"/v1/projects/{key}/events/batch"," request. It fires within milliseconds of the first ",[14,498,485],{}," tick.",[35,501,503],{"id":502},"widget-containers","Widget containers",[10,505,506,507,510],{},"To render a widget at a specific location on your page, place a container element with a ",[14,508,509],{},"data-os-widget"," attribute:",[43,512,515],{"className":513,"code":514,"language":48,"meta":49},[46],"\u003C!-- Waitlist widget -->\n\u003Cdiv data-os-widget=\"waitlist\">\u003C/div>\n\n\u003C!-- Contact form -->\n\u003Cdiv data-os-widget=\"contact\">\u003C/div>\n\n\u003C!-- Custom form by key -->\n\u003Cdiv data-os-form=\"frm_abc123\">\u003C/div>\n",[14,516,514],{"__ignoreMap":49},[10,518,519,520,523],{},"The embed script scans for these on load and renders the corresponding widget into each container using ",[14,521,522],{},"createWidgetRoot",". You can have multiple containers on the same page. Each gets its own Shadow DOM root.",[10,525,526,527,127],{},"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 ",[14,528,529],{},"document.body",[35,531,533],{"id":532},"what-you-are-actually-deploying","What you are actually deploying",[10,535,536,537,539],{},"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 ",[14,538,32],{}," 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.",[541,542,543,550,560,572,586],"faq-section",{},[544,545,547],"faq-item",{"question":546},"Does the script tag block page rendering?",[10,548,549],{},"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.",[544,551,553],{"question":552},"Can the embed widget CSS break my page styles?",[10,554,555,556,559],{},"No. Every widget renders inside a Shadow DOM root created with ",[14,557,558],{},"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.",[544,561,563],{"question":562},"When is window.OperatorStack available?",[10,564,565,566,568,569,571],{},"The ",[14,567,32],{}," 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 ",[14,570,155],{}," if you need to confirm initialization is fully done.",[544,573,575],{"question":574},"How does referral tracking work without any code from me?",[10,576,577,578,581,582,585],{},"The embed script reads the ",[14,579,580],{},"?ref="," query parameter from the URL on load and stores it in sessionStorage. When ",[14,583,584],{},"joinWaitlist()"," is called, the stored ref code is included automatically. You do not write any referral-handling code.",[544,587,589],{"question":588},"Why does the script tag use data-project instead of data-project-key?",[10,590,591,592,594,595,597,598,600,601,600,604,606],{},"The attribute is ",[14,593,28],{},". The embed script reads it via ",[14,596,72],{}," at load time. This is shorter and consistent with the other optional data attributes (",[14,599,85],{},", ",[14,602,603],{},"data-dev",[14,605,93],{},").",[608,609,610,611],"content-related-articles",{},"\n  ",[612,613,610,617],"contentrelatedcard",{"href":614,"title":615,"description":616},"/blog/comparisons/one-script-tag-vs-five-tools","One Script Tag vs Five SaaS Tools","Why replacing LaunchList, Tally, Mailchimp, GA, and spreadsheets with one embed cuts cost and setup time significantly.",[612,618,610,622],{"href":619,"title":620,"description":621},"/blog/guides/javascript-sdk","OperatorStack JavaScript SDK Reference","Complete method reference for the window.OperatorStack SDK: joinWaitlist, trackEvent, getShareLinks, and more with runnable examples.",[612,623,627],{"href":624,"title":625,"description":626},"/blog/how-to/static-html-landing-page","One Script Tag on a Static Landing Page","Wire up analytics, waitlist, and referral tracking on a plain HTML file with no backend.",[628,629],"cta-box",{"href":630,"label":631},"/","Get Started Free",{"title":49,"searchDepth":633,"depth":633,"links":634},2,[635,636,637,638,642,643,644],{"id":37,"depth":633,"text":38},{"id":97,"depth":633,"text":98},{"id":162,"depth":633,"text":163},{"id":211,"depth":633,"text":212,"children":639},[640],{"id":416,"depth":641,"text":417},3,{"id":453,"depth":633,"text":454},{"id":502,"depth":633,"text":503},{"id":532,"depth":633,"text":533},"guides","2026-05-28","Inside os.js: how OperatorStack's embed script loads, isolates widget CSS with Shadow DOM, exposes a ready SDK, and batches analytics without blocking your page.",false,"md",[651,652,654,656,658],{"question":546,"answer":549},{"question":552,"answer":653},"No. Every widget (waitlist form, contact form, custom forms, chat bubble) 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.",{"question":562,"answer":655},"The window.OperatorStack object is set synchronously when the script tag executes. All async methods (joinWaitlist, submitForm, sendContactMessage, showChat, hideChat) internally await a ready Promise, so you can call them before init completes. The ready property itself is a Promise you can await if you need to confirm initialization is done.",{"question":574,"answer":657},"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. The same applies to widget-based signups.",{"question":588,"answer":659},"The attribute is data-project. The embed script reads it via getAttribute('data-project') at load time. This is shorter and consistent with other data attributes the script reads (data-api, data-dev, data-exclude).","embed script architecture,embed script,javascript embed script,shadow dom widget isolation,operatorstack sdk,single script tag analytics,javascript iife bundle",{},true,"How os.js actually works: IIFE bundle, Shadow DOM isolation, SDK surface, event batching, and localStorage caching. For founders who want to understand what they're deploying.","Embed Script Architecture: One Script Tag, 10 Tools","/blog/guides/embed-script-architecture","7 min",null,"Article",{"title":5,"description":647},{"loc":665},"blog/guides/embed-script-architecture",[],"summary_large_image","tYNoin2rmW4hsVKmvDyV1S7_luRSd4pNeioxAqUpN28",1780165505739]