theyasked.me

Docs

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 a Referer.
  • 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

  1. widget.js mounts a closed Shadow DOM: launcher, backdrop, panel (or an inline container).
  2. 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.
  3. On first open it creates the iframe /embed/:ws?platform=web&tab=… (plus theme, accent, font, radius when the host passed a theme override; later changes go over postMessage).
  4. The frame posts theyasked:ready; the widget replies with identify. The frame exchanges the token for a session in the background; votes made meanwhile wait for it.
  5. 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 the window.LoopQueue stub are still read. Loop is a deprecated alias of TheyAsked, and if a page has both snippets the widget still mounts once.
  • data-loop-open opens the widget like data-theyasked-open.
  • window.LOOP_APP_HOST is read when window.THEYASKED_APP_HOST is not set.
  • The embed posts each event twice to its parent, as theyasked-embed / theyasked:* and then as loop-embed / loop:*. The widget handles each once; if you listen to the iframe yourself, filter on source.

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".