Web widget
One script tag. Identify users so votes carry company and plan.
One script tag puts your TheyAsked board inside your product: a feedback button (or an inline board), roadmap and a "What's new" changelog with an unread badge. Signed-in users are identified with SSO, so every vote carries their company, plan and MRR.
- ~7.5 KB gzip, no dependencies, Shadow DOM (your CSS can't leak in, ours can't leak out).
- The board itself runs in an iframe from your TheyAsked origin (
/embed/:ws); sessions are bearer tokens inside that frame, so it works with third-party cookies blocked (Safari, Brave, Chrome).
Install
<script>
(function (w, d, s) {
w.TheyAskedQueue = w.TheyAskedQueue || [];
w.TheyAsked = w.TheyAsked || function () { w.TheyAskedQueue.push(arguments); };
var js = d.createElement(s);
js.src = 'https://theyasked.me/widget.js';
js.async = true;
d.head.appendChild(js);
})(window, document, 'script');
TheyAsked('init', { workspace: 'acme' });
// Signed-in user? Token from YOUR backend (see SSO.md):
TheyAsked('identify', { token: '<%= theyasked_token(current_user) %>' });
</script>
Commands issued before the script loads are queued and replayed in order.
Send the user's group. Put the account, team, family or class the user belongs to in the token
as company (alias group): requests then count once per group and a shared subscription counts
once, not once per member. For example
"company": { "id": "acct_42", "name": "Bloom Beauty", "plan": "Enterprise", "mrr": 4200 } with
"attributes": { "role": "admin" } on the user. B2C models (families, schools, clans) and the revenue
rules: SSO.md. Using an AI assistant? Point it at
https://theyasked.me/llms.txt.
The launcher's look (side, label, icon, colours, offset, size) comes from Settings → Appearance,
so you can restyle it without touching the snippet; anything you pass to init wins. The launcher
appears once those settings have loaded (at most 1.5 s), so it never jumps sides.
Options: TheyAsked('init', options)
| Option | Default | |
|---|---|---|
workspace |
required | Workspace slug. |
mode |
'launcher' |
'launcher' floating button + panel · 'inline' board inside container · 'none' no button (open it yourself). |
container |
Selector or element for mode: 'inline'. |
|
tab |
'feedback' |
'feedback' · 'roadmap' · 'changelog'. |
board |
Board slug for the feed and new posts. | |
position |
Appearance ('right') |
'left' · 'right'. |
label |
Appearance ('Feedback') |
Button text and accessible name. The default is announced as "Feedback: tell us what you’d like". |
icon |
Appearance ('chat') |
'chat' · 'lightbulb' · 'megaphone' · 'heart'. |
size |
Appearance ('md') |
'sm' · 'md' · 'lg'. |
offset |
Appearance ({ x: 20, y: 20 }) |
Distance from the side and bottom edges, px (0–96). |
accent |
Appearance launcher colour, then the brand accent | Button colour; text colour is picked for contrast. |
theme |
'auto' |
'light' · 'dark' · 'auto', or a theme override { mode, accent, font, radius, … } (THEMING.md). |
token |
SSO token, same as calling identify. |
|
badge |
true |
Show the number of unread changelog entries on the button. |
Commands
| Command | |
|---|---|
TheyAsked('identify', { token }) |
Identify the signed-in user. Call again when the user changes. |
TheyAsked('logout') |
Forget the user (call on sign-out). |
TheyAsked('open', { tab?, post?, board? }) |
Open the panel, optionally on a tab or a specific request (post = slug). |
TheyAsked('open', { list }) |
Open a Vote list (Settings → Vote lists) in the panel, e.g. { list: 'integrations' }. |
TheyAsked('close') · TheyAsked('toggle') |
|
TheyAsked('theme', { mode?, accent?, font?, radius?, background?, … }) |
Change the embed's theme at runtime, e.g. follow your app's dark mode: TheyAsked('theme', 'dark'). Fields are validated by the embed (THEMING.md). |
TheyAsked('on', event, fn) · TheyAsked('off', event, fn?) |
Subscribe to events. |
TheyAsked('destroy') |
Remove everything. |
Any element with data-theyasked-open opens the widget: <a data-theyasked-open="changelog">What's new</a>.
Inline Vote lists: any element with data-theyasked-list="<key>" gets the list rendered in
place (its own frame in a closed Shadow DOM, sized to its content), also when your app adds the
element after init: <div data-theyasked-list="integrations"></div>.
Headless Vote lists (no UI, same identity as the widget; available once widget.js has
loaded, which fires the window event theyasked:loaded):
| Call | Resolves to |
|---|---|
TheyAsked.list(key) |
{ list, items }: items[] = { id, slug, title, summary, icon, iconUrl, viewerVoted, voteCount?, status? } |
TheyAsked.vote(postId) · TheyAsked.unvote(postId) |
{ voted, voteCount } |
TheyAsked.request(key, title) |
{ post } (created, maybe pendingModeration) or { matched } (already requested: the vote went there) |
They reject with an error carrying status and code (unauthorized when sign-in is needed).
TypeScript types for the snippet and the headless API: https://theyasked.me/widget.d.ts (TheyAskedHeadless, TheyAskedListItem, …; source apps/widget/src/types.ts). Guide: VOTE-LISTS.md.
Events
| Event | Data |
|---|---|
ready |
{ workspace, platform, tabs } |
open · close |
|
identified |
{ id, kind, name } |
identify_failed · token_expired |
{ reason }: mint a new token and call identify again |
post_created |
{ postId, slug, title, pending } |
voted |
{ postId, voted, voteCount } (+ listKey from a Vote list) |
requested |
{ listKey, postId, title, matched, pending }: a Vote list's “Request another…” (matched: the vote went to an existing request) |
unread |
{ count } unread changelog entries (drive your own badge with badge: false) |
TheyAsked('on', 'post_created', (e) => analytics.track('Feedback submitted', { title: e.title }));
TheyAsked('on', 'token_expired', async () => TheyAsked('identify', { token: await fetch('/theyasked-token').then((r) => r.text()) }));
"Powered by theyasked.me"
The panel shows a small "Powered by theyasked.me" badge under the list on every tab, linking to
https://theyasked.me/?ref=<workspace>&utm_source=powered_by&utm_medium=widget&utm_campaign=badge
in a new tab. It is part of every plan and can't be hidden from the snippet (THEMING.md
→ "Powered by"). It sets no cookie on your site: attribution is stored on theyasked.me only if
someone clicks through.
TheyAsked uses it too
The dashboard's Give feedback (user menu) is this widget for TheyAsked's own board, loaded
from /widget.js with mode: 'none' and identified with a token our server signs for the
signed-in staff user: the same init → identify → open sequence as above. See
DOGFOOD.md.
Identity, privacy, security
- The SSO token is sent to the frame with
postMessage(target origin pinned to your TheyAsked host) after the frame reports ready. It never appears in a URL, a server log or aReferer. - The widget only accepts messages whose origin is the TheyAsked host and whose source is its own frame.
- Events contain no session tokens or emails.
- Anonymous visitors can browse and, if the workspace allows it, vote and post. When they are identified later, their activity merges into their profile.
Content Security Policy
script-src https://theyasked.me;
frame-src https://theyasked.me;
connect-src https://theyasked.me; # widget-config + unread badge
Writes from the frame to /api/v1 are same-origin with the frame. If you set
settings.allowedOrigins, only those origins may make credentialed cross-origin writes; the widget
doesn't need it.
How it works
widget.jsmounts a closed Shadow DOM: launcher, backdrop, panel (or an inline container).- It fetches
/api/v1/w/:ws/widget-config(brand accent and launcher settings, tabs, latest changelog time) and, when there is something new since the last visit,/changelog/unread?since=for the badge. - On first open it creates the iframe
/embed/:ws?platform=web&tab=…(plustheme,accent,font,radiuswhen the host passed a theme override; later changes go overpostMessage). - The frame posts
theyasked:ready; the widget replies withidentify. The frame exchanges the token for a session in the background; votes made meanwhile wait for it. - The frame reports
theyasked:resize(desktop panel height),theyasked:close,theyasked:changelog_seen(badge cleared) and the domain events above.
The protocol is shared with mobile WebViews; see MOBILE.md.
Compatibility with Loop-era integrations
Pages installed before the rename keep working; nothing needs to change on your side:
window.Loop(…)and thewindow.LoopQueuestub are still read.Loopis a deprecated alias ofTheyAsked, and if a page has both snippets the widget still mounts once.data-loop-openopens the widget likedata-theyasked-open.window.LOOP_APP_HOSTis read whenwindow.THEYASKED_APP_HOSTis not set.- The embed posts each event twice to its parent, as
theyasked-embed/theyasked:*and then asloop-embed/loop:*. The widget handles each once; if you listen to the iframe yourself, filter onsource.
Develop and test
pnpm --filter @loop/widget build # apps/widget/dist/widget.js
pnpm build # copies it into apps/web/dist → served at /widget.js
pnpm dev # /widget.js proxied from the Worker on :8787
cd apps/widget && python3 -m http.server 5199
open "http://127.0.0.1:5199/demo/index.html?host=http://localhost:5173" # host app: identify, open, events
open "http://127.0.0.1:5199/demo/inline.html?host=http://localhost:5173" # inline mode
open "http://127.0.0.1:5199/demo/mobile.html?host=http://localhost:5173" # WebView simulator
The demos sign tokens in the page with the local secret acme-dev-sso-secret; never do that in
production.
Mobile apps
In iOS/Android apps, present the board as a modal sheet with your own ✕ and hideHeader=1 (the board shows "← All requests" inside). See docs/v2/MOBILE.md → "Recommended presentation: a modal sheet".