postMessage channel with an explicit target on both sides.
The widget embeds the agent directly: the brain, not a page. It needs no project and no published page: an agent with knowledge and a site key is everything an embed requires.
What the visitor sees
The component is the wondeya ask-block, exactly as it looks on a wondeya page: a heading that invites the question, a rounded input with a send button, suggested questions to start from, and the AI disclosure notice. When the visitor asks, the agent answers with generative UI (text alongside rendered components), using the same renderer as wondeya pages. The conversation extends down your page and your page scrolls; the component never scrolls inside itself.Install it
The loader CDN is not live yet:
cdn.wondeya.com starts serving with the SDK 0.1.0 release, so you can create your site key and place the snippet now, and the widget appears the moment the loader is published.div where the component should appear: the middle of a landing section, the end of a product page, a sidebar column. The script tag can go anywhere; before the closing body tag is the classic spot, and head is safe too, because if the loader runs before your container is parsed, it waits for DOMContentLoaded and looks once more. If [data-wondeya] does not suit your markup, point the loader at any element with data-container (see the options below).
That is the whole installation, with one condition: your site’s origin must be registered on that site key. Until it is, the frame refuses to load. That is what stops somebody who copied your key from putting your agent on a page you do not control. An unregistered domain is a blank frame, by design.
Getting a site key
A site key identifies one agent and authorizes nothing. It is public by design: it ships inside your HTML where anyone can read it. What protects the endpoint is the origin allowlist plus wondeya’s anti-abuse stack, not the secrecy of the key. You create one for an agent together with the exact origins allowed to embed it. Today that happens over MCP: thecreate_site_key tool takes the agent and the allowlist,
wdy_site_…), the one place the full value appears: listings show a display prefix only. list_site_keys and rotate_site_key cover the rest of the lifecycle; the tool reference documents all three.
Rules for allowedOrigins:
- Up to 20 entries, each an exact origin: scheme, host, optional port. No path, no trailing slash, no wildcards.
https://*.example.comis refused: one forgotten subdomain takeover on your side would otherwise become the right to frame your agent. http://is accepted only on loopback (http://localhost:3000), for development. A production site on plain HTTP would hand the “who may frame this” decision to whoever controls the network.- An origin that could never work is refused with an error that names it, so you can fix what you typed.
- The list is fixed when the key is created and cannot be edited afterwards: to add or change an origin, create a new key with the full list and swap the snippet.
The console screen for site keys and embed origins is on the way; until it lands, the MCP tools are how you create and manage them.
Calling the API before the loader arrives
async means the loader may not have parsed when your own code runs. Add the queue stub above the snippet and call wondeya(...) whenever you like; calls are replayed in order once the widget exists:
Options
All optional, all set as attributes on thescript tag.
Appearance
The widget ships with wondeya’s look; these attributes override it for this embed. They travel to the frame in the iframe URL and are validated on both sides: an invalid value is one console warning and that key falling back to its default, never a broken widget.
Colors are 6-digit hex and nothing else; radii and typefaces are closed lists. There is no way to pass free CSS to the widget, on purpose: the same closed vocabulary that guards a wondeya page guards the embed.
data-font="inherit" cannot use your web fonts. The widget is an iframe, a separate document, and fonts loaded by your page never cross into it. inherit reads your page’s computed font stack off body and forwards it, so the widget matches your site wherever that stack resolves to system or locally installed fonts, and falls back to the widget’s default typeface where it does not.
How it sizes itself
The component behaves like a block of your own page, and the two dimensions come from two different places:- Width follows the container. The iframe is always 100% of the element it mounted into, so you control width the way you control any layout: size the container. A 360px column gives you a narrow component; a full-width section gives you a wide one.
- Height follows the conversation. The frame measures its own document and reports the height over the
postMessagechannel; the loader applies it to the iframe. As answers arrive and components render, the component grows and the content below it moves down: your page scrolls, the component does not. There is nothing to configure. The loader sanity-checks every reported height before applying it, so even a compromised frame could not paint your page into a mile-long iframe.
loading="lazy": a component below the fold costs your page nothing until the visitor scrolls near it.
The JavaScript API
init accepts every option in the two tables above, under the same names without the data- prefix and with the same validation; container may be a CSS selector string or the DOM element itself. Anything the loader does not recognize (an unknown command, a malformed argument) is one console.warn prefixed [wondeya] and a no-op, never an exception in your page.
Events
If you want to know the loader itself is live before any of that, read
window.wondeya.loaded.
identify is not authentication
It is display data. Any script on your page, such as an analytics tag or a browser extension, can send it, so nothing is ever authorized by it and it must never carry a secret. Traits are capped at 12 keys and 120 characters each, and anything that is not a short string is dropped before it leaves your page.
React
@wondeya/react is not on the npm registry yet; it publishes with the same SDK 0.1.0 release as the loader.
div at its position in your tree and mounts the widget inside it. className and style land on that div; that is how you control width and placement, exactly as for any other block. appearance takes the same values as the data-* attributes above, with the same validation and the same inherit limitation; onEvent receives every event from the table above. On unmount it removes everything it added, which is the part a plain script tag cannot do in a single-page app.
The wrapper is deliberately thin: every behaviour lives in the loader, so a React app and a script tag get the same widget rather than two that drift.
Content-Security-Policy
If your site sends a CSP (and it should), add these three sources:script-src: the loader itself. With a nonce-based policy, put your nonce on thescripttag; the loader adds no inline scripts of its own.frame-src: the widget’s iframe. Without it the frame is blocked and your container stays empty.connect-src: the chat request. It is made by the iframe, from its own origin, so strictly it is governed by the iframe’s policy and not yours. Some CSP linters and older browsers still flag it; adding it costs nothing and prevents a confusing report.
style-src 'unsafe-inline'. The loader styles only the elements it created, using element style attributes, which a CSP does not govern. It loads no fonts, no images and no analytics from your page.
wondeya’s side of the deal
The embed document ships its own policy, built per site key on the server before the document exists:default-src 'none', then only what the page genuinely needs;connect-srcis the frame’s own origin plus wondeya’s API;img-srcis the frame’s own origin,data:URIs and wondeya’s CDN;object-src 'none'andbase-uri 'none';- when bot protection is on, Cloudflare Turnstile’s challenge origin (
https://challenges.cloudflare.com) joinsscript-srcandframe-src; frame-ancestorslists only the origins you registered. No wildcards are accepted, and an empty allowlist becomes'none'rather than*. If any registered origin fails to validate, the whole list collapses to'none'instead of silently serving a weaker policy than you configured.
Versioning and the SRI trade-off
Every release is published to two URLs:
Subresource Integrity is incompatible with the auto-updating channel, and that is not a bug in either.
integrity="sha384-…" tells the browser to refuse a file whose bytes changed, which is exactly what the auto-updating channel does when a fix ships. Pick one:
- auto-update, no SRI: you get security fixes without touching your site, and you trust wondeya’s CDN. The right default for almost everyone.
- pinned plus SRI: a byte-exact guarantee, so a compromise of the CDN cannot reach your visitors. In exchange, a fix reaches you when you deploy, not before.
v0.1 and the next breaking release opens v0.2, with the old one kept alive. From 1.0.0 onwards it is v1, and every 1.x fix arrives on it.
Troubleshooting
The loader never throws into your page: a widget must not be able to break the site it is on. Everything it complains about is aconsole.warn prefixed [wondeya].