# TheyAsked > TheyAsked (https://theyasked.me) is a hosted feedback board for software products: your end users post feature requests, vote and follow a roadmap and changelog, inside your web app (a widget), your mobile app (a WebView embed) or on a public portal. Your product team sees who asked, which group (company, family, team, class…) they belong to and what it is worth. This file tells an AI coding assistant how to integrate it correctly. Served at https://theyasked.me/llms.txt. Every guide concatenated: https://theyasked.me/llms-full.txt ## Integration decision tree 1. Web app (React, Vue, Rails, Django, plain HTML…): use the **web widget** (one script tag). Identify signed-in users with an SSO token from your backend: `TheyAsked('identify', { token })`. 2. Native or cross-platform mobile app (iOS, Android, React Native, Flutter): open the **embed URL in a WebView**: `https://theyasked.me/embed/?platform=ios|android&hideHeader=1&token=`. - Present it as a **modal sheet** (bottom sheet / full-screen modal with a close button), not a pushed screen: two back arrows (yours and the board's) confuse users. - With `hideHeader=1` your app draws the title bar; the board shows its own "← All requests" link on request pages. - Back handling: listen for the `theyasked:navigate` bridge event; while `canGoBack` is true, send the embed back (`window.TheyAskedEmbed.back()`) instead of closing; when it is false (or on `theyasked:close`), dismiss the sheet. - Refresh an expired token by injecting `window.TheyAskedEmbed.identify(newToken)` on `theyasked:token_expired`. 3. A link in an email or menu: the public portal `https://theyasked.me/?token=` (the token is stripped from the URL after sign-in). 4. Backend sync, CRM / support-tool integrations, reporting: the **REST API** `https://theyasked.me/api/public/v1` with an API key (`Authorization: Bearer ta_live_…`, created in Settings → API). OpenAPI: https://theyasked.me/api/public/v1/openapi.json 5. React to events (new request, status change, shipped): **webhooks** (HMAC-SHA256 signed), Settings → Webhooks. 6. Let an AI agent read and act on feedback: the **MCP server** at `https://theyasked.me/mcp` (OAuth or API key). ## Always send the user's group as `company` Every end user usually belongs to a group: the account, team, family, class or store that shares a subscription. Send it in the token as `company` (alias: `group`, same shape; if both are present `company` wins). TheyAsked then counts a request once per group, rolls revenue up once per group (never double-counted across members) and lets the product team segment by group. "company" is the field name for every model, B2B or B2C; the workspace owner picks the word the dashboard shows (Settings → General → Customers). | Business model | What to send as `company` | Typical `attributes.role` | | --- | --- | --- | | B2B SaaS | the customer account / organization / workspace | admin, member, viewer | | Agency / consultancy tools | the client | manager, contributor | | Marketplaces, e-commerce platforms | the store / seller / merchant | owner, staff | | Education | the school, or the class | teacher, student, parent | | Healthcare | the clinic / practice | practitioner, admin | | Consumer subscriptions (family plans) | the family / household | parent, kid, adult | | Communities, games | the club / team / clan / guild | captain, member | | Individuals only (no shared account) | omit `company` | (optional) | Group shape: `company: { id, name, plan?, mrr?, currency?, employeeCount?, attributes? }`. `id` is your stable id for the group (string or number). `attributes` is a flat map (≤ 50 keys) of strings, numbers, booleans: e.g. `members`, `kids`, `seats`, `industry`, `renewalDate` (ISO date), `health` (good / at_risk / poor or a 0–100 score), `status` (`churned` tracks lost revenue). Revenue rules: - Shared subscription (one bill for the group): put it on the group, `company.mrr`. It counts once per group, however many members vote. - Individual subscription (a person with no group pays for themselves): put `mrr` on the user and omit `company`. - Inside a group, only `company.mrr` counts: a member's personal `mrr` is ignored for revenue. If members of one group pay separately, send the group's total as `company.mrr`. - `mrr` is whole currency units per month (49 means $49/month). `plan` is a label ("Pro", "Family", "Enterprise"). Roles: send `attributes.role` on the user (parent / kid, admin / member, teacher / student). The product team can then filter insights by role ("what do parents want vs kids"). ## Token signing rules - Sign **on your server only**, with the workspace SSO secret from Settings → Single sign-on. Read it from an environment variable (`THEYASKED_SSO_SECRET`); never hard-code it, never ship it to a browser bundle or a mobile binary, never log it. - Algorithm: **HS256** JWT (`{"alg":"HS256","typ":"JWT"}`). Any JWT library works. - Claims: `id` (your stable user id, string) and/or `email` (at least one), `name`, `avatarURL` (https), `company` (or `group`), `plan`, `mrr`, `attributes`, `platform` (`web` | `ios` | `android`), `appVersion`, `locale`, `country` (ISO-3166 alpha-2), `iat`, `exp`. - Set `exp` to about an hour and mint a fresh token per page load or app session. Tokens whose `iat` is older than 24 h (workspace setting) are rejected. - Pass the token to the widget (`TheyAsked('identify', { token })`), the embed URL (`?token=`) or `POST https://theyasked.me/api/v1/w//identify` with `{ "token" }`. - Test a token without side effects in Settings → Single sign-on → "Test a token". Example payloads (add `iat` = now and `exp` = now + 3600 when you sign each with HS256): B2B SaaS: {"id":"u_981","email":"lina@bloom.co","name":"Lina","company":{"id":"acct_42","name":"Bloom Beauty","plan":"Enterprise","mrr":4200,"employeeCount":120,"attributes":{"seats":40,"industry":"retail","renewalDate":"2027-03-01","health":"good"}},"attributes":{"role":"admin"}} Family app (one shared family subscription): {"id":"u_55","name":"Maya","company":{"id":"fam_9","name":"The Rivera family","plan":"Family","mrr":12,"attributes":{"members":4,"kids":2}},"attributes":{"role":"kid","ageBand":"9-12"}} Individual consumer app (no group, personal subscription): {"id":"u_77","email":"sam@example.com","name":"Sam","plan":"Premium","mrr":9,"attributes":{"role":"subscriber"}} ## Vote lists (curated "vote or request" widgets) A Vote list is a short list of options from one board, each with a logo tile, a name, a one-line summary and a Vote button, plus an inline "Request another…" field (e.g. "Vote or request an integration"). The workspace owner builds it in Settings → Vote lists and gives it a key (`integrations`). Put it where the question comes up: - Web, button: `TheyAsked('open', { list: 'integrations' })` opens it in the widget panel (after the usual `TheyAsked('init', { workspace })` and `identify`). - Web, inline: `
` (auto-mounted, sized to its content, also when rendered after init). - Mobile: the embed URL with `list=`: `https://theyasked.me/embed/?list=integrations&platform=ios|android&hideHeader=1&token=`, presented as a modal sheet; `back` on a list emits `theyasked:close`. - Headless (your own UI, the widget's identity): after the `theyasked:loaded` window event, `await TheyAsked.list('integrations')` → `{ list, items: [{ id, title, summary, icon, iconUrl, viewerVoted, voteCount?, status? }] }`, `TheyAsked.vote(id)`, `TheyAsked.unvote(id)`, `TheyAsked.request('integrations', 'Salla')` → `{ post }` (created) or `{ matched }` (already requested: the user's vote went there). Errors carry `status` and `code` (`unauthorized` = sign-in needed). - Events: `TheyAsked('on', 'voted', fn)` (`listKey` included) and `TheyAsked('on', 'requested', fn)` (`{ listKey, postId, title, matched, pending }`); bridge events `theyasked:voted` / `theyasked:requested` in WebViews. - Logos: each request's icon is set by the product team (a website domain like `shopify.com`, an emoji or an uploaded image) in the dashboard or over MCP (`update_post` with `icon` and `summary`). Don't hot-link logos yourself; use `iconUrl` from the headless API. - Everything a list records is an ordinary vote / request with the source `vote_list`, attributed to the identified user and their group. ## Common mistakes - Signing the token in the browser or the app (the secret leaks; anyone can impersonate anyone). Sign on your backend and return the token from an authenticated endpoint. - Missing `company`: every family member, teammate or student then looks like an unrelated individual, demand is over-counted and revenue cannot be rolled up. Send the group for every user who has one. - Putting the group's shared subscription on every user (`mrr` per member): revenue is multiplied by the member count. Use `company.mrr`. - A changing or display-name group `id`: use your database id, not the name. - Sending children's emails or other personal data you don't need. For kids, send only your user id, a first name or nickname and `attributes.role: "kid"`; omit `email`, `avatarURL`, birth dates and precise ages (use a band like "9-12" if you need one). - PII in `attributes` (phone numbers, addresses, health or payment data): attributes are shown to the product team; keep them to segmentation facts. - Long-lived tokens minted at build or deploy time (rejected as `too_old`), or tokens in URLs that get logged (prefer the widget's `identify` call; the portal strips `?token=`). - A pushed navigation screen with `hideHeader=1` and no back handling in mobile apps (see the decision tree). - Calling the widget's `identify` before the user is known, or not calling `TheyAsked('logout')` on sign-out. ## Docs - [SSO: identify your users, B2B and B2C grouping, signing snippets](https://theyasked.me/docs/sso) - [Web widget](https://theyasked.me/docs/widget) - [Mobile apps: iOS, Android, React Native, Flutter](https://theyasked.me/docs/mobile) - [REST API (OpenAPI 3.1)](https://theyasked.me/api/public/v1/openapi.json) - [Claude & MCP](https://theyasked.me/docs/mcp) - [Vote lists: "vote or request an integration" widgets](https://theyasked.me/docs/vote-lists) - [Theming](https://theyasked.me/docs/theming) - [Quickstart](https://theyasked.me/docs/quickstart) ## Optional - [Full integration docs in one file](https://theyasked.me/llms-full.txt) ============================================================================== ============================================================================== # SSO: identify your logged-in users Your users are already signed in to your product. TheyAsked should know who they are the moment they open the widget, the mobile board or the portal, with no second sign-up. You do that by signing a short token on **your backend** with the workspace's SSO secret and handing it to TheyAsked. TheyAsked verifies it, creates or updates the person (and their company, plan and MRR) and starts a session. Everything here works the same for the web widget, mobile WebViews and the public portal. ``` your backend ──signs──▶ token ──▶ widget / WebView / portal link ──▶ POST /api/v1/w/:ws/identify ├─ verify HS256 signature (constant time) ├─ upsert end-user + company + attributes ├─ merge their anonymous votes/comments └─ session (bearer for widget/WebView, cookie for portal) ``` ## 1. Get the secret Dashboard → Settings → SSO. Copy the secret into your server's environment (`THEYASKED_SSO_SECRET`). It never goes to a browser or an app binary. Rotating it invalidates tokens signed with the old one. Local development: the seeded `acme` workspace uses `acme-dev-sso-secret`. ## 2. Token format An **HS256 JWT** (`{"alg":"HS256","typ":"JWT"}`), signed with the secret. Any JWT library works. A compact form without a header (`base64url(payload).base64url(hmac)`) is also accepted. | Claim | Required | Notes | | --- | --- | --- | | `id` | yes\* | Your stable user id. The same id in two of your products is the same TheyAsked person. | | `email` | yes\* | Recommended: enables email notifications and email matching. | | `name` | | Shown on comments and to your team. | | `avatarURL` | | https image URL (`avatar` is accepted too). | | `company` | | The account / group the user belongs to (a customer account, store, family, class, clan…). Alias: `group`, same shape; if both are sent `company` wins. `{ id, name, plan?, mrr?, currency?, employeeCount?, attributes? }`. Upserted by `id` (your stable id, string or number). `mrr` is whole currency units per month and drives revenue-at-stake. See [Grouping users](#grouping-users-b2b-and-b2c). | | `plan`, `mrr` | | For users without a group. Inside a group, `company.plan` wins and a member's own `mrr` is ignored for revenue. | | `attributes` | | Flat map (≤ 50 keys, keys ≤ 64 chars) of strings (≤ 2048 chars) / numbers / booleans / null for segments. `role` is the conventional key for roles. | | `platform` | | `web` · `ios` · `android` (also set by the embed URL). | | `appVersion`, `locale`, `country` | | Mobile and analytics context. `country` is ISO-3166 alpha-2. | | `iat` | | Issued-at, seconds. Tokens older than the workspace's max age (default 24 h) are rejected. | | `exp` | | Expiry, seconds. Recommended: 1 hour. | \* `id` and/or `email`. With `ssoMatchBy: 'id'` (default) TheyAsked matches on `id`, falling back to `email` for email-only tokens. With `ssoMatchBy: 'email'` it matches on email first (pick it if your previous SSO matched people by email). **Compatible with Canny- and Upvoty-style claims.** Canny tokens (`companies: [{ id, name }]`) and Upvoty Custom SSO tokens (`{ id?, email?, name?, avatar?, extra_data? }`, scalar `extra_data` → attributes, `extra_data.company`, `extra_data.group`, `extra_data.plan` and `extra_data.mrr` promoted) are accepted unchanged. Moving from either means swapping the secret and the URL, nothing else. ## Grouping users: B2B and B2C Most people who use your product belong to something: a customer account, a store, a family, a class, a clan. Send that as `company` in the token (alias `group`, same shape). The field is called `company` for every business model, B2B or B2C; it simply means "the account / group this person belongs to". Why it matters: - **Requests count once per group.** Five people from one family asking for the same thing are one family's ask, not five unrelated people. Each request records `company_count`, the distinct groups among its voters ("asked by 3 families"), and Demand weighs groups more than individual votes. - **Revenue is not double-counted.** A shared subscription sent as `company.mrr` counts once per group per request, however many members vote. - **Segments.** The product team can filter and split insights by group, by the group's plan and attributes, and by each person's role. Without a group every member looks like an unrelated individual: demand is over-counted and revenue cannot be rolled up. Send the group for every user who has one. ### What to send as the group | Business model | Group (`company`) | Typical `attributes.role` | | --- | --- | --- | | B2B SaaS | the customer account / organization / workspace | admin, member, viewer | | Agency / consultancy tools | the client | manager, contributor | | Marketplaces, e-commerce platforms | the store / seller / merchant | owner, staff | | Education | the school, or the class | teacher, student, parent | | Healthcare | the clinic / practice | practitioner, admin | | Consumer subscriptions (family plans) | the family / household | parent, kid, adult | | Communities, games | the club / team / clan / guild | captain, member | | Individuals only (no shared account) | omit `company` | (optional) | Shape: `{ id, name, plan?, mrr?, currency?, employeeCount?, attributes? }`. - `id`: your stable database id for the group (string or number). Never the display name, which can change or collide. - `mrr`: whole currency units per month for the whole group (49 means $49/month). `plan` is a label. - `attributes`: flat map, ≤ 50 keys (≤ 64 chars each) of strings (≤ 2048 chars), numbers, booleans or null. Insights read a few of them: - `renewalDate` (ISO date) and `health` (`good` / `at_risk` / `poor`, or a 0–100 score) drive "revenue at renewal risk". - `status: "churned"` (plus `churnedAt`) tracks churned revenue. - Anything else (`members`, `kids`, `seats`, `industry`…) is a segment fact. If a token carries both `company` and `group`, `company` wins. Upvoty-style `extra_data.group` is promoted the same way as `extra_data.company`. ### What your team sees: the group label Dashboard → Settings → General → **Customers** sets what groups are called in your workspace: Company (default), Account, Organization, Team, Workspace, Family, Household, School, Class, Club, Clinic, Store, or a custom singular and plural (≤ 24 characters each). The dashboard navigation, the People and Companies pages, a request's "Who asked" panel ("3 families"), insights columns, segments and revenue, Pulse, insight CSV exports, MCP text and Ask answers all use that word (the full data export keeps its `company` columns). Turn off **Users belong to groups** if your users are individuals only; group UI is then hidden. Field names in the token, the REST API and MCP stay `company`, `companyId`, `list_companies` and `get_company` whatever the label. The public portal never shows groups. Settings → Single sign-on shows how many people identified in the last 30 days carried a group. When that share is low, it suggests sending `company`. ### Payload examples Sign each payload as in [section 3](#3-sign-the-token-server-side) (add `iat` / `exp`). **(a) B2B SaaS: seats, MRR, renewal and health** ```json { "id": "u_981", "email": "lina@bloom.co", "name": "Lina", "company": { "id": "acct_42", "name": "Bloom Beauty", "plan": "Enterprise", "mrr": 4200, "currency": "USD", "employeeCount": 120, "attributes": { "seats": 40, "industry": "retail", "renewalDate": "2027-03-01", "health": "good" } }, "attributes": { "role": "admin" } } ``` Unlocks: requests count once per account; revenue at stake per account; "ship X before Bloom renews"; filter admins vs members; segment by industry or seats. **(b) Commerce platform: the store is the group** ```json { "id": "u_3307", "email": "omar@sunnyside.shop", "name": "Omar", "company": { "id": 88120, "name": "Sunnyside Ceramics", "plan": "Advanced", "mrr": 299, "attributes": { "gmvBand": "50k-250k", "products": 340, "country": "AE", "channels": "online+pos" } }, "attributes": { "role": "owner" } } ``` Staff of the same store send the same `company.id` with `"role": "staff"`. `mrr` is what the merchant pays you, not their sales. Unlocks: requests count once per store; revenue at stake per store; owners vs staff; segment by GMV band or catalogue size ("high-GMV stores want this 3× more"). **(c) Family app: parents and kids in one family** Parent: ```json { "id": "u_54", "email": "ana@example.com", "name": "Ana", "company": { "id": "fam_9", "name": "The Rivera family", "plan": "Family", "mrr": 12, "attributes": { "members": 4, "kids": 2 } }, "attributes": { "role": "parent" } } ``` Kid (no email, no avatar, no birth date; here the group is sent with the `group` alias): ```json { "id": "u_55", "name": "Maya", "group": { "id": "fam_9", "name": "The Rivera family", "plan": "Family", "mrr": 12 }, "attributes": { "role": "kid", "ageBand": "9-12" } } ``` `group` and `company` are the same claim; if a token sent both, `company` would win. Use one consistently. Unlocks: requests count once per family; revenue at stake per family ($12, not $12 per member); filter parents vs kids ("what do parents want vs kids"). **(d) School: teachers and students** ```json { "id": "stu_2291", "name": "Leo", "company": { "id": "class_7b_2026", "name": "Year 7B", "attributes": { "school": "Northfield Primary", "schoolId": "sch_14", "grade": 7, "students": 28 } }, "attributes": { "role": "student" } } ``` A person has one group, so pick one level: - **Class** when feedback is driven by classroom use. Put the school in the group's attributes (`school`, `schoolId`) so you can still segment by school. Don't put the school's subscription on every class (each class counts its own `mrr`, so the total would be multiplied); send the class's share or omit `mrr`. - **School** when the school is the paying customer and you care about school-level demand and revenue. Put the class in the user's attributes (`class: "7B"`) if you need it. Unlocks: requests count once per class (or school); teachers vs students vs parents; segment by school, grade or size. **(e) Game or community: the clan** ```json { "id": "p_77120", "name": "NightOwl", "company": { "id": "clan_5512", "name": "Iron Wolves", "attributes": { "members": 24, "region": "EU", "tier": "gold" } }, "attributes": { "role": "captain" } } ``` A free product sends no `mrr`. If clans buy a subscription, add `"plan": "Clan Pro", "mrr": 15` to the group. Players are often minors: follow the privacy rules below. Unlocks: requests count once per clan; captains vs members; segment by region or tier; revenue per clan if clans pay. **(f) Individual consumer app: personal subscription, no group** ```json { "id": "u_77", "email": "sam@example.com", "name": "Sam", "plan": "Premium", "mrr": 9, "attributes": { "role": "subscriber" } } ``` No `company`: the person is their own unit. Turn off "Users belong to groups" if nobody has one. Unlocks: revenue at stake from personal subscriptions; segment by plan (free vs premium) and by any user attribute. ### Counting roles Send roles on the **user** as `attributes.role` (parent / kid, admin / member, teacher / student, captain / member). Insights then: - filter by role: `attr.role=parent` matches the person's attribute first, then the group's; - split any view by role or any other attribute (`segments?by=attr:role`): "parents want X, kids want Y". Roles don't change how votes or revenue are counted; they only make segments possible. ### How revenue is aggregated Per request: 1. Each distinct group among the voters adds its `company.mrr` **once**. 2. Voters **without** a group add their own `mrr`. 3. Inside a group only `company.mrr` counts; a member's personal `mrr` is ignored for revenue. If members of one group pay separately, send the group's total as `company.mrr`. 4. For plan segments, the group's `plan` wins over the user's `plan`. Worked example: the Rivera family (4 members, `company.mrr: 12`). Ana, Maya and her brother vote for "Shared chore calendar". The request shows **$12 at stake from 1 family** (3 voters), not $36. If Sam (no group, `mrr: 9`) votes too, it becomes $21 at stake: 1 family plus 1 individual. ### Privacy - For children send only your id, a first name or nickname, and `attributes.role: "kid"`. No email, avatar, birth date or exact age; use an age band such as `"9-12"` if you need one. - Attributes are shown to your team. Keep them to segmentation facts: no phone numbers, addresses, health or payment data. ### Using an AI assistant? Point it at [theyasked.me/llms.txt](https://theyasked.me/llms.txt) (all guides in one file: [llms-full.txt](https://theyasked.me/llms-full.txt)). Settings → Widget, Settings → Single sign-on and the onboarding Connect step also have an **Integrate with AI** prompt to copy into your assistant. It includes your workspace slug, embed URL and group label, never the SSO secret: it tells the assistant to read the secret from the `THEYASKED_SSO_SECRET` environment variable. ## 3. Sign the token (server side) Every snippet produces the same token. Keep `exp` short (an hour) and mint a fresh one per page load or app session. ### Node.js (`jsonwebtoken`) ```js import jwt from 'jsonwebtoken'; export function theyaskedToken(user) { return jwt.sign( { id: String(user.id), email: user.email, name: user.name, avatarURL: user.avatarUrl, company: { id: String(user.account.id), name: user.account.name, plan: user.account.plan, mrr: user.account.mrr }, attributes: { role: user.role }, }, process.env.THEYASKED_SSO_SECRET, { algorithm: 'HS256', expiresIn: '1h' }, ); } ``` Without dependencies (Node 18+, Workers, Deno, Bun): `import { signIdentityToken } from '@loop/core'` or the 12 lines of WebCrypto in `apps/widget/demo/index.html`. ### Python (`PyJWT`) ```python import os, time, jwt def theyasked_token(user): now = int(time.time()) return jwt.encode( { "id": str(user.id), "email": user.email, "name": user.name, "company": {"id": str(user.account.id), "name": user.account.name, "plan": user.account.plan, "mrr": user.account.mrr}, "iat": now, "exp": now + 3600, }, os.environ["THEYASKED_SSO_SECRET"], algorithm="HS256", ) ``` ### Ruby (`jwt` gem) ```ruby require "jwt" def theyasked_token(user) now = Time.now.to_i JWT.encode( { id: user.id.to_s, email: user.email, name: user.name, company: { id: user.account.id.to_s, name: user.account.name, plan: user.account.plan, mrr: user.account.mrr }, iat: now, exp: now + 3600 }, ENV.fetch("THEYASKED_SSO_SECRET"), "HS256" ) end ``` ### PHP (`firebase/php-jwt`) ```php use Firebase\JWT\JWT; function theyasked_token(array $user): string { $now = time(); return JWT::encode([ 'id' => (string) $user['id'], 'email' => $user['email'], 'name' => $user['name'], 'company' => ['id' => (string) $user['account_id'], 'name' => $user['account_name'], 'plan' => $user['plan'], 'mrr' => $user['mrr']], 'iat' => $now, 'exp' => $now + 3600, ], getenv('THEYASKED_SSO_SECRET'), 'HS256'); } ``` ### Go (`github.com/golang-jwt/jwt/v5`) ```go import ( "os" "time" "github.com/golang-jwt/jwt/v5" ) func TheyAskedToken(u User) (string, error) { now := time.Now() claims := jwt.MapClaims{ "id": u.ID, "email": u.Email, "name": u.Name, "company": map[string]any{ "id": u.Account.ID, "name": u.Account.Name, "plan": u.Account.Plan, "mrr": u.Account.MRR, }, "iat": now.Unix(), "exp": now.Add(time.Hour).Unix(), } return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(os.Getenv("THEYASKED_SSO_SECRET"))) } ``` ### C# / .NET (`System.IdentityModel.Tokens.Jwt`) ```csharp using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Text; using System.Text.Json; using Microsoft.IdentityModel.Tokens; public static string TheyAskedToken(User user) { var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Environment.GetEnvironmentVariable("THEYASKED_SSO_SECRET")!)); var company = JsonSerializer.Serialize(new { id = user.AccountId, name = user.AccountName, plan = user.Plan, mrr = user.Mrr }); var token = new JwtSecurityToken( claims: new[] { new Claim("id", user.Id), new Claim("email", user.Email), new Claim("name", user.Name), new Claim("company", company, JsonClaimValueTypes.Json), }, notBefore: DateTime.UtcNow, expires: DateTime.UtcNow.AddHours(1), signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256)); // JwtSecurityToken adds iat via the handler when SetDefaultTimesOnTokenCreation is on (default). return new JwtSecurityTokenHandler().WriteToken(token); } ``` ### Java (`io.jsonwebtoken:jjwt` 0.12) ```java import io.jsonwebtoken.Jwts; import io.jsonwebtoken.security.Keys; import java.nio.charset.StandardCharsets; import java.time.Instant; import java.util.Date; import java.util.Map; public static String theyaskedToken(User user) { var key = Keys.hmacShaKeyFor(System.getenv("THEYASKED_SSO_SECRET").getBytes(StandardCharsets.UTF_8)); var now = Instant.now(); return Jwts.builder() .claim("id", user.getId()) .claim("email", user.getEmail()) .claim("name", user.getName()) .claim("company", Map.of("id", user.getAccountId(), "name", user.getAccountName(), "plan", user.getPlan(), "mrr", user.getMrr())) .issuedAt(Date.from(now)) .expiration(Date.from(now.plusSeconds(3600))) .signWith(key, Jwts.SIG.HS256) .compact(); } ``` > jjwt rejects HMAC keys shorter than 256 bits. TheyAsked secrets are 43-character random strings, so this > only matters for hand-made test secrets. ## 4. Hand the token to TheyAsked | Surface | How | | --- | --- | | Web widget | `TheyAsked('identify', { token })` or `TheyAsked('init', { workspace, token })`. Re-call it after your user switches account; `TheyAsked('logout')` on sign-out. See [WIDGET.md](https://theyasked.me/docs/widget). | | Mobile WebView | Load `/embed/:ws?token=…&platform=ios`. Refresh by injecting `window.TheyAskedEmbed.identify(newToken)`. See [MOBILE.md](https://theyasked.me/docs/mobile). | | Portal link | `https://theyasked.me/acme?token=…` (e.g. a "Feedback" link in your app menu). The portal identifies, sets its own cookie, and strips the token from the URL. | | Server to server | `POST /api/v1/w/:ws/identify` with `{ "token" }` returns `{ sessionToken, identity }`. | **Compatibility with Loop-era integrations.** Tokens are unchanged by the rename: a backend that signs with the old `LOOP_SSO_SECRET` variable keeps working (only the secret value matters), and `Loop('identify', { token })` and `window.LoopEmbed.identify(token)` remain aliases of the calls above. **Identify never blocks.** The board renders straight away; identify runs in the background and any vote or comment made meanwhile waits for it, so it is attributed to the identified person. If the visitor had already voted anonymously, those votes, comments, posts and follows are merged into the identified profile (duplicates dropped) and the anonymous record is deleted. ## 5. Errors `401` with `error.details.reason`: | reason | Meaning | Fix | | --- | --- | --- | | `bad_signature` | Signed with another secret | Check `THEYASKED_SSO_SECRET` and the workspace slug | | `bad_algorithm` | Not HS256 (`alg: none`, RS256) | Use HS256 | | `malformed` | Not a JWT / bad base64 | Pass the raw token string | | `invalid_claims` | Neither `id` nor `email`, or a claim has the wrong type | See the claim table | | `expired` | Past `exp` | Mint a fresh token (the embed emits `theyasked:token_expired`) | | `too_old` | `iat` older than the max age (default 24 h) | Mint tokens per session, not at deploy time | | `not_yet_valid` | `iat` more than 5 minutes in the future | Fix server clock | Blocked end-users get `403`. ## 6. Security model - The secret is per workspace, server-only and rotatable. TheyAsked stores sessions as SHA-256 hashes. - Signatures are compared in constant time; `alg` is pinned to HS256. - Freshness: `exp` plus the workspace's max-age on `iat` limit replay of leaked tokens. - Widget/WebView sessions are bearer tokens kept in memory and `sessionStorage` inside the TheyAsked frame, never third-party cookies. The widget hands the SSO token to the frame with `postMessage` targeted at the TheyAsked origin, so it never appears in a URL, a server log or a `Referer`. - Bridge events sent to the host page never contain tokens or email addresses. ## 7. Fallbacks for people who aren't signed in to your product - **Anonymous**: allowed per workspace (`allowAnonymous`, `anonymousVoting`). Anonymous activity merges into the person's profile when they later identify or use a magic link. - **Magic link**: the portal's "Sign in" emails a one-time link (20 minutes). The resulting account is matched to an SSO-identified person with the same email. ============================================================================== ============================================================================== # Web widget 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 ```html ``` 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](https://theyasked.me/docs/sso#grouping-users-b2b-and-b2c). 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](https://theyasked.me/docs/theming)). | | `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](https://theyasked.me/docs/vote-lists) (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](https://theyasked.me/docs/theming)). | | `TheyAsked('on', event, fn)` · `TheyAsked('off', event, fn?)` | Subscribe to events. | | `TheyAsked('destroy')` | Remove everything. | Any element with `data-theyasked-open` opens the widget: `What's new`. **Inline Vote lists:** any element with `data-theyasked-list=""` 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`: `
`. **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](https://theyasked.me/docs/vote-lists). ## 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`) | ```js 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=&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](https://theyasked.me/docs/theming) → "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](./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](https://theyasked.me/docs/mobile). ## 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 ```bash 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". ============================================================================== ============================================================================== # TheyAsked in your mobile app TheyAsked runs inside your iOS, Android, React Native or Flutter app as a WebView pointed at the embed surface. That is what Canny and Featurebase ship too: one code path, always up to date, no SDK release when the board changes. Your app supplies the identity (an SSO token from your backend), so people never sign in twice, and every vote and post is tagged with the platform. ## The embed URL ``` https://theyasked.me/embed/?platform=ios&token= ``` | Param | Values | | | --- | --- | --- | | `platform` | `ios` · `android` · `web` | Recorded as the source of posts, votes and comments (`ios` / `android`), and on the person. | | `token` | SSO JWT | Signed by **your backend** ([SSO.md](https://theyasked.me/docs/sso)). Identified in the background; stripped from history. | | `theme` | `light` · `dark` · `auto` | Match your app. Omit to use the workspace's setting (or the system). | | `accent`, `font`, `radius` | hex, font id, 0/4/6/10/16 | Per-app theme tweaks ([THEMING.md](https://theyasked.me/docs/theming#url-overrides-embed)). | | `tab` | `feedback` · `roadmap` · `changelog` | Start tab. `changelog` is the "What's new" view. | | `board` | board slug | Default board for the feed and new posts. | | `post` | post slug | Open a request directly (deep links). | | `list` | list key | Show a [Vote list](https://theyasked.me/docs/vote-lists) (`integrations`) instead of the board: logo tiles, Vote buttons and “Request another…”. No feed header; your ✕ closes it. | | `hideHeader` | `1` | Hide the embed header when your native nav bar shows the title. | | `close` | `1` / `0` | Show / hide the embed's close button (emits `theyasked:close`). | | `locale` | e.g. `ar` | Sets the document language. | The page is designed at 360–390 px: 44 px touch targets, safe-area insets (`viewport-fit=cover`), bottom-sheet composer, sticky "Ask for something" above the home indicator. Sessions are bearer tokens held in the page (memory + `sessionStorage`); third-party cookies are never needed. ### Send the user's group Put the user's account, family, team or class in the token as `company` (alias `group`) so requests count once per group and a shared subscription isn't counted once per member. A family app, parent and kid in the same family (the kid has no email): ```json { "id": "u_54", "email": "ana@example.com", "name": "Ana", "attributes": { "role": "parent" }, "company": { "id": "fam_9", "name": "The Rivera family", "plan": "Family", "mrr": 12 } } { "id": "u_55", "name": "Maya", "attributes": { "role": "kid", "ageBand": "9-12" }, "company": { "id": "fam_9", "name": "The Rivera family", "plan": "Family", "mrr": 12 } } ``` More models (B2B, stores, schools, clans) and the revenue rules: [SSO.md](https://theyasked.me/docs/sso#grouping-users-b2b-and-b2c). Using an AI assistant? Point it at https://theyasked.me/llms.txt. ## Recommended presentation: a modal sheet **Open the board as a modal (bottom sheet / full-screen modal), not as a pushed screen.** - Your app owns **one control: the ✕ (close)** on the sheet's header, which dismisses the whole board. - The board owns **navigation inside it**: with `hideHeader=1` it shows **"← All requests"** on request pages. - A pushed screen gives users **two back arrows** (your navigation bar's and ours) that do different things, which is confusing. A modal avoids that: ✕ always means "close feedback", ← always means "back to the list". Recommended setup: - iOS: `.sheet` / `.fullScreenCover` (SwiftUI) or `UISheetPresentationController` with a large detent; a header with your title + ✕. - Android: `ModalBottomSheet` (Compose) or a full-screen dialog; a header with title + ✕. Map the system back to `TheyAskedEmbed.back()` while `canGoBack` is true, otherwise dismiss. - React Native / Flutter: a modal route or bottom sheet with a ✕ header. - URL: `/embed/?platform=ios|android&hideHeader=1&token=`. - Listen for `theyasked:close` (sent when `back` is called on the list) to dismiss the sheet. Only use a pushed screen if your app already hides its own navigation bar for it; then leave `hideHeader` off so the board shows its own header with Back. ## The bridge The embed posts the same JSON object to every host it can find: | Host | Channel the embed calls | | --- | --- | | iOS `WKWebView` | `window.webkit.messageHandlers.theyasked.postMessage(msg)` (object) | | Android `WebView` | `window.TheyAskedBridge.postMessage(json)` (`@JavascriptInterface`) | | React Native | `window.ReactNativeWebView.postMessage(json)` | | Flutter `webview_flutter` | `TheyAskedBridge.postMessage(json)` (JavaScript channel) | ```json { "source": "theyasked-embed", "type": "theyasked:post_created", "postId": "pst_…", "slug": "dark-mode", "title": "Dark mode", "pending": false } ``` | `type` | Payload | Typical reaction | | --- | --- | --- | | `theyasked:ready` | `workspace`, `platform`, `tabs` (+ `list` for a Vote list) | Hide your spinner | | `theyasked:identified` | `id`, `kind`, `name` | | | `theyasked:token_expired` | `reason` | Fetch a fresh token from your backend and push it (below) | | `theyasked:identify_failed` | `reason` | Log it; the board keeps working anonymously if allowed | | `theyasked:post_created` | `postId`, `slug`, `title`, `pending` | Analytics, a thank-you | | `theyasked:voted` | `postId`, `voted`, `voteCount` (+ `listKey` in a Vote list) | Analytics | | `theyasked:requested` | `listKey`, `postId`, `title`, `matched`, `pending` | A Vote list's “Request another…” (`matched`: already requested, the vote went there) | | `theyasked:changelog_seen` | `at` (unix ms) | Clear your own "What's new" badge | | `theyasked:navigate` | `tab`, `post` | Update your nav title | | `theyasked:close` | | Dismiss the screen | | `theyasked:resize` | `height` | Only useful for self-sizing containers | | `theyasked:open_url` | `url` (https) | Open it in the system browser (below) | Commands into the page: evaluate `window.TheyAskedEmbed.identify("")`, `window.TheyAskedEmbed.logout()`, `window.TheyAskedEmbed.open("changelog")` / `window.TheyAskedEmbed.open(null, "")` or `window.TheyAskedEmbed.theme({ mode: "dark" })`. (A `postMessage({ source: 'theyasked-host', type: 'identify', token })` works as well, and so does `{ source: 'theyasked-host', type: 'theme', theme: { mode: 'dark' } }`.) ### Follow your app's dark mode Load the embed with the app's current mode (`theme=dark|light`), then push changes while the screen is open; nothing reloads. Colours, fonts and corners come from **Settings → Appearance** ([THEMING.md](https://theyasked.me/docs/theming)), including "Hide the embed header in mobile apps". ```swift // SwiftUI: in your UIViewRepresentable func updateUIView(_ web: WKWebView, context: Context) { let mode = context.environment.colorScheme == .dark ? "dark" : "light" web.evaluateJavaScript("window.TheyAskedEmbed && window.TheyAskedEmbed.theme({ mode: '\(mode)' })") } ``` ```kotlin // Compose: re-run when the system theme flips val dark = isSystemInDarkTheme() LaunchedEffect(dark) { web?.evaluateJavascript("window.TheyAskedEmbed && window.TheyAskedEmbed.theme({ mode: '${if (dark) "dark" else "light"}' })", null) } ``` ```tsx // React Native const scheme = useColorScheme(); useEffect(() => { ref.current?.injectJavaScript(`window.TheyAskedEmbed && window.TheyAskedEmbed.theme({ mode: '${scheme ?? 'light'}' }); true;`); }, [scheme]); ``` ```dart // Flutter: e.g. in didChangePlatformBrightness() final dark = WidgetsBinding.instance.platformDispatcher.platformBrightness == Brightness.dark; _controller.runJavaScript("window.TheyAskedEmbed && window.TheyAskedEmbed.theme({ mode: '${dark ? 'dark' : 'light'}' })"); ``` Payloads never contain session tokens or email addresses. ### Compatibility with Loop-era apps Apps built before the rename keep working without a release: - `webkit.messageHandlers.loop` and `window.LoopBridge` still receive every event, in the old shape (`{ "source": "loop-embed", "type": "loop:close", … }`). The new channels get the new shape. - React Native and the widget iframe have a single channel, so they receive both shapes, new first. Filter on `source` (the snippets above do). - `window.LoopEmbed` is an alias of `window.TheyAskedEmbed`, and commands sent with `source: 'loop-host'` or `'loop-widget'` are accepted. When you next ship, register the `theyasked` handler / `TheyAskedBridge` channel instead of the old one (registering both makes the events arrive twice). ### External links (`theyasked:open_url`) The embed shows a small "Powered by theyasked.me" badge at the bottom of each tab (mandatory, [THEMING.md](https://theyasked.me/docs/theming)). With `platform=ios|android` and a native channel present, tapping it does not navigate the WebView: the embed posts `{ "source": "theyasked-embed", "type": "theyasked:open_url", "url": "https://theyasked.me/?ref=…" }` (`loop:open_url` on the Loop-era channels). Open `url` in the system browser: ```swift case "theyasked:open_url": if let s = msg["url"] as? String, let url = URL(string: s), url.scheme == "https" { UIApplication.shared.open(url) } ``` ```kotlin "theyasked:open_url" -> msg.optString("url").takeIf { it.startsWith("https://") }?.let { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(it))) } ``` React Native: `Linking.openURL(msg.url)`; Flutter: `launchUrl(Uri.parse(msg['url']), mode: LaunchMode.externalApplication)` (url_launcher). Without a native channel the link opens normally (`target="_blank"`), so apps that don't handle the event still work; adding the handler only keeps the user out of an in-app page. ### Back button: list ↔ request page With `hideHeader=1` your app draws the top bar, so the embed shows an in-page **"← All requests"** link on request pages. To make your own back button (and Android's back / iOS swipe) behave like a browser instead of closing the screen: - Listen for `theyasked:navigate`; its payload has `canGoBack: true` while a request page is open. - When `canGoBack` is true, send the embed back instead of popping your screen: ```js // any host: postMessage webview.postMessage(JSON.stringify({ source: 'theyasked-host', type: 'back' })); // or evaluate JS (iOS evaluateJavaScript / Android evaluateJavascript / Flutter runJavaScript) window.TheyAskedEmbed.back(); ``` - If `back` arrives while the list is showing, the embed emits `theyasked:close`, so you can pop your screen. - A Vote list (`?list=`) is always the root view: `back` emits `theyasked:close` and `canGoBack` is never true. Swift: ```swift if canGoBack { webView.evaluateJavaScript("window.TheyAskedEmbed.back()") } else { dismiss(animated: true) } ``` Kotlin (Compose `BackHandler`): ```kotlin BackHandler(enabled = canGoBack) { webView.evaluateJavascript("window.TheyAskedEmbed.back()", null) } ``` React Native: in your `onMessage`, track `canGoBack` and call `webviewRef.current?.injectJavaScript('window.TheyAskedEmbed.back(); true;')` from your header/`BackHandler`. Flutter: in `PopScope(canPop: !canGoBack, onPopInvoked: (_) => controller.runJavaScript('window.TheyAskedEmbed.back()'))`. ### Token refresh Mint tokens with `exp` ≈ 1 hour. If the screen stays open longer, or the user switches account: 1. The embed emits `theyasked:token_expired` when a token is rejected as expired. 2. Your app asks its backend for a new token. 3. It calls `window.TheyAskedEmbed.identify(newToken)`. The session continues; nothing reloads. On sign-out in your app call `window.TheyAskedEmbed.logout()` (or just discard the WebView). ### "What's new" badge outside the WebView `GET /api/v1/w//changelog/unread?since=` → `{ "count": 2, "latest": { … } }`. Store the `at` from `theyasked:changelog_seen` and pass it as `since`. --- ## iOS: WKWebView + SwiftUI ```swift import SwiftUI import WebKit struct TheyAskedFeedbackView: UIViewRepresentable { let workspace: String let fetchToken: () async throws -> String // calls YOUR backend var tab: String = "feedback" var onClose: () -> Void = {} func makeCoordinator() -> Coordinator { Coordinator(self) } func makeUIView(context: Context) -> WKWebView { let config = WKWebViewConfiguration() config.userContentController.add(context.coordinator, name: "theyasked") let web = WKWebView(frame: .zero, configuration: config) web.isOpaque = false web.scrollView.contentInsetAdjustmentBehavior = .never // the page handles safe areas context.coordinator.web = web Task { await context.coordinator.load() } return web } func updateUIView(_ web: WKWebView, context: Context) {} static func dismantleUIView(_ web: WKWebView, coordinator: Coordinator) { web.configuration.userContentController.removeScriptMessageHandler(forName: "theyasked") } final class Coordinator: NSObject, WKScriptMessageHandler { let parent: TheyAskedFeedbackView weak var web: WKWebView? init(_ parent: TheyAskedFeedbackView) { self.parent = parent } @MainActor func load() async { let token = (try? await parent.fetchToken()) ?? "" var c = URLComponents(string: "https://theyasked.me/embed/\(parent.workspace)")! c.queryItems = [ .init(name: "platform", value: "ios"), .init(name: "tab", value: parent.tab), .init(name: "close", value: "1"), .init(name: "token", value: token), ] web?.load(URLRequest(url: c.url!)) } func userContentController(_ uc: WKUserContentController, didReceive message: WKScriptMessage) { guard let msg = message.body as? [String: Any], let type = msg["type"] as? String else { return } switch type { case "theyasked:close": parent.onClose() case "theyasked:token_expired": Task { @MainActor in guard let token = try? await parent.fetchToken() else { return } let js = "window.TheyAskedEmbed && window.TheyAskedEmbed.identify(\(Self.jsString(token)))" _ = try? await web?.evaluateJavaScript(js) } case "theyasked:post_created": print("TheyAsked: new idea", msg["title"] ?? "") default: break } } static func jsString(_ s: String) -> String { let data = try! JSONSerialization.data(withJSONObject: [s]) return String(String(data: data, encoding: .utf8)!.dropFirst().dropLast()) } } } // Usage struct SettingsScreen: View { @State private var showFeedback = false var body: some View { Button("Feedback") { showFeedback = true } .sheet(isPresented: $showFeedback) { TheyAskedFeedbackView(workspace: "acme", fetchToken: Api.shared.theyaskedToken) { showFeedback = false } .ignoresSafeArea() } } } ``` UIKit: the coordinator above works unchanged with a plain `WKWebView` in a view controller. ## Android: WebView + Jetpack Compose ```kotlin class TheyAskedBridge(private val onMessage: (JSONObject) -> Unit) { @JavascriptInterface fun postMessage(json: String) = onMessage(JSONObject(json)) } @SuppressLint("SetJavaScriptEnabled") @Composable fun TheyAskedFeedbackScreen( workspace: String, fetchToken: suspend () -> String, // calls YOUR backend onClose: () -> Unit, ) { val scope = rememberCoroutineScope() var web by remember { mutableStateOf(null) } var initialToken by remember { mutableStateOf(null) } LaunchedEffect(Unit) { initialToken = runCatching { fetchToken() }.getOrDefault("") } BackHandler(enabled = web?.canGoBack() == true) { web?.goBack() } // in-embed navigation val token = initialToken ?: return AndroidView( modifier = Modifier.fillMaxSize().systemBarsPadding(), factory = { ctx -> WebView(ctx).apply { settings.javaScriptEnabled = true settings.domStorageEnabled = true // sessionStorage for the TheyAsked session addJavascriptInterface(TheyAskedBridge { msg -> post { when (msg.optString("type")) { "theyasked:close" -> onClose() "theyasked:token_expired" -> scope.launch { val fresh = fetchToken() evaluateJavascript("window.TheyAskedEmbed && window.TheyAskedEmbed.identify(${JSONObject.quote(fresh)})", null) } } } }, "TheyAskedBridge") val url = Uri.parse("https://theyasked.me/embed/$workspace").buildUpon() .appendQueryParameter("platform", "android") .appendQueryParameter("close", "1") .appendQueryParameter("token", token) .build().toString() loadUrl(url) web = this } }, ) } ``` Only expose `TheyAskedBridge` to your TheyAsked origin: keep navigation inside the WebView limited to it (a `WebViewClient.shouldOverrideUrlLoading` that opens other hosts in the browser). ## React Native: `react-native-webview` ```tsx import { useCallback, useRef } from 'react'; import { WebView, type WebViewMessageEvent } from 'react-native-webview'; export function TheyAskedFeedback({ workspace, token, fetchToken, onClose }: { workspace: string; token: string; // from YOUR backend fetchToken: () => Promise; onClose: () => void; }) { const ref = useRef(null); const uri = `https://theyasked.me/embed/${workspace}?platform=${Platform.OS}&close=1&token=${encodeURIComponent(token)}`; const onMessage = useCallback(async (e: WebViewMessageEvent) => { const msg = JSON.parse(e.nativeEvent.data); if (msg.source !== 'theyasked-embed') return; if (msg.type === 'theyasked:close') onClose(); if (msg.type === 'theyasked:token_expired') { const fresh = await fetchToken(); ref.current?.injectJavaScript(`window.TheyAskedEmbed && window.TheyAskedEmbed.identify(${JSON.stringify(fresh)}); true;`); } }, [fetchToken, onClose]); return ( ); } ``` (`import { Platform } from 'react-native'`.) Expo: identical with `react-native-webview` from Expo. ## Flutter: `webview_flutter` ```dart import 'dart:convert'; import 'dart:io' show Platform; import 'package:flutter/material.dart'; import 'package:webview_flutter/webview_flutter.dart'; class TheyAskedFeedback extends StatefulWidget { const TheyAskedFeedback({super.key, required this.workspace, required this.fetchToken}); final String workspace; final Future Function() fetchToken; // calls YOUR backend @override State createState() => _TheyAskedFeedbackState(); } class _TheyAskedFeedbackState extends State { late final WebViewController _controller; @override void initState() { super.initState(); _controller = WebViewController() ..setJavaScriptMode(JavaScriptMode.unrestricted) ..addJavaScriptChannel('TheyAskedBridge', onMessageReceived: (m) async { final msg = jsonDecode(m.message) as Map; switch (msg['type']) { case 'theyasked:close': if (mounted) Navigator.of(context).pop(); case 'theyasked:token_expired': final fresh = await widget.fetchToken(); await _controller.runJavaScript('window.TheyAskedEmbed && window.TheyAskedEmbed.identify(${jsonEncode(fresh)})'); } }); _load(); } Future _load() async { final token = await widget.fetchToken(); final uri = Uri.https('theyasked.me', '/embed/${widget.workspace}', { 'platform': Platform.isIOS ? 'ios' : 'android', 'close': '1', 'token': token, }); await _controller.loadRequest(uri); } @override Widget build(BuildContext context) => Scaffold(body: WebViewWidget(controller: _controller)); } ``` ## Testing without an app `apps/widget/demo/mobile.html` is a phone-frame simulator: it signs a token in the page with the dev secret, loads the embed with `platform=ios|android`, logs every bridge event and has buttons for "push fresh token", "push expired token" (exercises `theyasked:token_expired` → refresh) and "logout". ```bash pnpm dev # TheyAsked on :5173 / :8787 cd apps/widget && python3 -m http.server 5199 open "http://127.0.0.1:5199/demo/mobile.html?host=http://localhost:5173" ``` Sign a token from the command line with `npx tsx scripts/sso-token.mts --platform ios --urls 1`. ## Checklist - [ ] Tokens are signed on your server, `exp` ≈ 1 h, `platform` set. - [ ] `theyasked:token_expired` fetches a fresh token and calls `TheyAskedEmbed.identify`. - [ ] `theyasked:open_url` opens the URL in the system browser. - [ ] `theyasked:close` dismisses the screen; Android back navigates inside the embed first. - [ ] `domStorageEnabled` on Android; WebView restricted to your TheyAsked origin. - [ ] Theme passed (`theme=dark`) when your app is in dark mode, and `TheyAskedEmbed.theme()` called when it changes. ============================================================================== ============================================================================== # Vote lists A Vote list is a small, curated vote widget for one decision: **“Vote or request an integration”**, “Which template next?”, “Which language should we add?”. It shows a short list of options, each with a logo tile, a name, a one-line summary and a **Vote** button, plus an inline **“Request another…”** field. You put it exactly where the question comes up in your product: next to your integrations page, in onboarding, in a mobile settings screen. Everything in a list is an ordinary request on one of your boards, so votes, voters, revenue, statuses, notifications, webhooks, insights and MCP all work as usual. Votes and requests that come through a list are tagged with the source **Vote list** (`vote_list`). ## How it works - A list reads the requests of **one board** (an *Integrations* board, say). Closed requests never show; shipped ones are hidden unless you turn that off; team-only and unapproved ones are never public. - Order: **manual** (drag in the builder), **most voted** or **newest**; at most *N* items. - Each request carries an optional **icon** (a website's logo, an emoji, or an uploaded image) and a **summary** (one plain-text line, up to 140 characters). Without an icon the tile shows the first letter of the name. - **Request another…** looks for the same name on the board first (case, accents, spaces and words like “integration” don't matter: “woo commerce” is *WooCommerce*). When it exists, the person's vote goes there (“Already requested, we added your vote”); otherwise a new request is created on the board (“Added, we'll let you know when it ships”). Your workspace's rules apply: moderation (new requests wait for approval), anonymous voting / posting, rate limits, and the board must accept new posts. - People who vote or request follow the request: they hear from you when it ships. ## The integrations example, end to end ### 1. A board Settings → Boards → **New board** “Integrations”. Turn off *Show on the portal* only if you don't want it on the public portal at all: a list needs its board on the portal (the votes go through the public API). ### 2. Requests with logos Create one request per integration (Inbox → **New post**, or the REST API / MCP). On each request page, **Vote list card**: - **Website**: type the domain (`shopify.com`, `salla.sa`). TheyAsked fetches the site's icon once (its `apple-touch-icon`, the `` it declares, or `/favicon.ico`, the best one of 64 px or more) and keeps a copy. If the site has nothing usable, a letter tile is shown. - **Emoji**: pick one from the grid or paste your own. - **Upload**: a square image, at least 64 px (PNG, JPEG, WebP or SVG). - **Summary**: one line under the name, e.g. “Sync orders, customers and refunds”. From an AI assistant (MCP): `update_post` with `{ "post": "shopify", "icon": { "kind": "domain", "value": "shopify.com" }, "summary": "Sync orders and customers" }`. ### 3. The list Settings → **Vote lists** → **New list**: pick the board, a title (“Vote or request an integration”), an optional eyebrow (“Integrations”) and a key (`integrations`). The builder then lets you: - edit the subtitle, the request field's placeholder and button label, and the list's own icon; - choose the order, how many to show, whether to hide shipped ones, show vote counts or status, and whether to take new requests; - drag the requests into order (or focus a handle and use ↑ / ↓); - preview the real list in light or dark, as a phone or as the widget panel. ### 4. Put it in your product Install the widget once ([WIDGET.md](https://theyasked.me/docs/widget)) and identify signed-in users with SSO ([SSO.md](https://theyasked.me/docs/sso)), then pick one of: **A button that opens the list** in the widget panel (a full-screen sheet on phones): ```html ``` **Inline**, in the page (the list sizes itself to its content; any number per page, also when your app renders them later): ```html
``` **Mobile WebView**, presented as a modal sheet with your own ✕ ([MOBILE.md](https://theyasked.me/docs/mobile)): ``` https://theyasked.me/embed/?list=integrations&platform=ios&hideHeader=1&token= ``` **Headless JS** (no UI; draw it yourself, same identity as the widget): ```js window.addEventListener('theyasked:loaded', async () => { const { list, items } = await TheyAsked.list('integrations'); // items: [{ id, slug, title, summary, icon, iconUrl, viewerVoted, voteCount?, status? }] await TheyAsked.vote(items[0].id); // TheyAsked.unvote(id) takes it back const r = await TheyAsked.request('integrations', 'Salla'); if (r.matched) console.log('Already requested; vote added to', r.matched.title); else console.log('Created', r.post.title, r.pendingModeration ? '(waits for review)' : ''); }); ``` Errors reject with `{ status, code }`: `unauthorized` (sign-in needed: anonymous voting or posting is off), `forbidden` (the list doesn't take requests), `not_found`, `rate_limited`. Types: https://theyasked.me/widget.d.ts (`TheyAskedHeadless`, `TheyAskedListItem`, …). ### 5. Events | Where | Event | Payload | | --- | --- | --- | | Widget | `TheyAsked('on', 'voted', fn)` | `{ postId, voted, voteCount, listKey }` | | Widget | `TheyAsked('on', 'requested', fn)` | `{ listKey, postId, title, matched, pending }` | | WebView bridge | `theyasked:voted` | same as above | | WebView bridge | `theyasked:requested` | same as above | | WebView bridge | `theyasked:ready` | `{ workspace, platform, tabs: [], list }` | `back` (or `TheyAskedEmbed.back()`) on a list emits `theyasked:close`: the list is the root view, so your app dismisses its sheet. ### 6. Analytics - **Insights → Sources**: votes and new requests from lists show as **Vote list**; the inbox and insights filters take *Source: Vote list*. - Each request's **Who asked** panel lists the voters with their source, company, plan and revenue, like any other vote. - Exports (`posts.csv`, `votes.csv`) carry the source, and posts also their `summary` and `icon`. - Webhooks: `post.created` / `vote.created` with `source: "vote_list"` and the list key. ## API Public (end-user, `/api/v1/w/:ws`, same session as the widget / embed): | Method | Path | | | --- | --- | --- | | `GET` | `/lists/:key` | `{ list, items }`; `voteCount` / `status` only when the list shows them. Viewer-independent part edge-cached 15 s; `viewerVoted` per session. | | `POST` | `/lists/:key/requests` | `{ title }` → `201 { post, pendingModeration }` or `200 { matched, pendingModeration: false }` | | `POST` / `DELETE` | `/posts/:id/vote` | Voting, with `X-TheyAsked-Source: vote_list` | | `GET` | `/logos/:domain` | The logo of a domain icon this workspace uses (a lettermark SVG when the site has none; `?fallback=none` answers 404 instead). Other domains: 404. | Dashboard (`/api/admin`, admin or owner): `GET|POST /vote-lists`, `GET|PATCH|DELETE /vote-lists/:id` (PATCH is partial: absent fields stay), `POST /vote-lists/:id/reorder { itemOrder }`, and `GET /logos/:domain` (preview for the icon editor). Post icons and summaries: `PATCH /api/admin/posts/:id { icon, summary }`. Details: [API.md](https://theyasked.me/api/public/v1/openapi.json). Public REST API (workspace API key, `/api/public/v1`): `GET|POST /vote-lists`, `GET|PATCH /vote-lists/{id}` (id or key; `board` by id, slug or name, `itemOrder` by request id or slug; admin role and the `voteLists` plan flag for writes). Each list comes with its `snippets` (`widget`, `inline`, `webview`). MCP: `list_vote_lists` (read), `create_vote_list` / `update_vote_list` (`posts:write`, admin role, audit-logged; same fields as the dashboard, partial update by id or key; the result has the list, what it shows now and the three snippets), and `icon` / `summary` on `create_post` / `update_post`. An assistant can set up the whole integrations example: `create_post` per integration with an `icon` and `summary`, then `create_vote_list { key: "integrations", board: "Integrations", title: "Vote or request an integration" }`. ## Plans Vote lists are on every plan (plan flag `voteLists`, editable in `/platform/plans`). Turning it off pauses them: lists can't be created or edited and stop being served (404); nothing is deleted. ## Logos: what is fetched, and how safely Only domains used as an icon in your workspace are ever fetched, from the Worker, never from your users' browsers. Every request (and every redirect) is checked: https only, no private, loopback, link-local or cloud-metadata addresses, at most 3 redirects, 5 requests and 256 KB per logo, 5 s timeouts. The bytes decide the type: PNG, JPEG, GIF, WebP, ICO, or an SVG without scripts, event handlers, external references or entities. The result is stored in your workspace's file storage (deleted with the workspace) and served with `nosniff` and a sandbox CSP; a site without a usable logo is retried after a week. Without file storage (R2) on a deployment, tiles show the letter instead. ============================================================================== ============================================================================== # TheyAsked v2: Integrations, MCP and AI This guide covers everything that talks to TheyAsked from outside the dashboard: the public REST API, webhooks, the MCP server (Claude Code, Claude Desktop, claude.ai connectors) and the AI layer. Contracts live in `@loop/shared` (`schemas/public-api.ts`). The OpenAPI 3.1 document is served at `GET /api/public/v1/openapi.json`. Placeholders below: `https://theyasked.me` is the hosted service (use your own `APP_URL` if you run a deployment), and `ta_live_…` is an API key. --- ## 1. API keys Workspace admins create keys in **Settings → API** (`POST /api/admin/api-keys`). - The format is `ta_live_` followed by 32 base62 characters. The full key is returned **once**. TheyAsked stores only its SHA-256 and shows the first 12 characters (`prefix`) afterwards. - Scopes: the coarse `read` / `write` (original; `read` grants every read scope, `write` every scope) and/or granular ones: `posts:read`, `people:read`, `changelog:read`, `insights:read`, `posts:write`, `votes:write`, `comments:write`, `tags:write`, `changelog:write`, `people:write`. `:write` also grants `:read`. Settings → API has a picker; ticking every box stores the coarse pair too. Each REST route checks one granular scope (in the OpenAPI document as `x-theyasked-granular-scope`), so existing `read` / `write` keys behave exactly as before. Logic: `scopeGrants` / `normalizeScopes` in `packages/shared/src/constants.ts`. - Every action a key takes is attributed to the teammate who created it, for example as the author of status changes and internal notes. - Rate limit: 120 requests per minute per key (Workers Rate Limiting binding `RL_API`). Over the limit you get a `429` with `Retry-After`. - `last_used_at` is refreshed at most once a minute, to save D1 writes. - Revoking a key (`DELETE /api/admin/api-keys/:id`) takes effect immediately. The row stays for audit. ## 2. REST API quickstart Base URL: `https://theyasked.me/api/public/v1`. Send `Authorization: Bearer ta_live_…`. Conventions: - Bodies are JSON. Timestamps are unix ms. Money is whole currency units per month. - Lists are cursor-paginated: pass `limit` (1–100) and `cursor`, where `cursor` is the previous response's `nextCursor`. - Errors look like `{ "error": { "code", "message", "details?" } }`. The codes are the same as the rest of the API (see API.md). - Wherever a path takes `{id}`, you can pass the post **id or slug**. For users and companies you can pass the **TheyAsked id or your own external id**. ```bash KEY=ta_live_... BASE=https://theyasked.me/api/public/v1 # Who am I? curl -s $BASE/me -H "Authorization: Bearer $KEY" # Push your customers' plan / MRR / attributes (≤200 companies + ≤200 users per call, upsert by your ids) curl -s $BASE/users/identify-batch -H "Authorization: Bearer $KEY" -H 'content-type: application/json' -d '{ "companies": [{ "id": "acct_42", "name": "Bloom Beauty", "plan": "Enterprise", "mrr": 4200 }], "users": [{ "id": "u_981", "email": "lina@bloom.co", "name": "Lina", "companyId": "acct_42", "attributes": { "role": "owner" } }] }' # File a request on behalf of a user (they vote for + follow it). externalId makes it idempotent. curl -s $BASE/posts -H "Authorization: Bearer $KEY" -H 'content-type: application/json' -d '{ "title": "Export rewards to CSV", "body": "Finance needs a monthly export", "board": "feature-requests", "author": { "userId": "u_981" }, "externalId": "zendesk-77812" }' # Add a vote for another customer, with importance curl -s $BASE/posts/export-rewards-to-csv/votes -H "Authorization: Bearer $KEY" \ -H 'content-type: application/json' -d '{ "userId": "u_1200", "importance": "critical" }' # What do Enterprise customers want most, by revenue at stake? curl -s "$BASE/insights/top-requests?by=revenue&plan=Enterprise&limit=5" -H "Authorization: Bearer $KEY" # Move a request to Planned (followers get notified) and add an internal note curl -s -X PATCH $BASE/posts/export-rewards-to-csv -H "Authorization: Bearer $KEY" \ -H 'content-type: application/json' -d '{ "status": "planned", "statusNote": "Targeting May" }' curl -s $BASE/posts/export-rewards-to-csv/comments -H "Authorization: Bearer $KEY" \ -H 'content-type: application/json' -d '{ "body": "Bloom asked again on the QBR", "internal": true }' ``` ### Endpoints | Method | Path | Scope | Notes | | --- | --- | --- | --- | | GET | `/me` | read | Workspace and scopes of the key | | GET | `/posts` | read | `q`, `board`, `status`, `statusCategory`, `tag`, `plan`, `companyId`, `owner`, `moderation`, `source`, `archived`, `merged`, `sort=votes\|mrr\|trend\|newest\|oldest\|activity\|companies` | | POST | `/posts` | write | `{ title, body?, board?, status?, tagIds?, author?: { userId?, email?, name? }, externalId? }` → 201 (200 if `externalId` already exists) | | GET | `/posts/{id}` | read | Detail: comments incl. internal notes, status history, top 50 voters, voter segments (plans with MRR, platforms, importance) | | PATCH | `/posts/{id}` | write | `title, body, board, status, statusNote, notify, tagIds, ownerUserId, eta, impact, effort, confidence, priority, internalNote, archived, pinned, moderation` | | POST | `/posts/{id}/merge` | write | `{ intoPostId }`. Votes are deduplicated; comments and followers move | | GET | `/posts/{id}/voters` | read | Sorted by company/user MRR | | GET | `/posts/{id}/who` | read | Who asked for it: companies, plans + revenue, platforms, importance, vote notes | | POST | `/posts/{id}/votes` | write | `{ userId? \| email?, name?, importance?, note? }`. Idempotent | | DELETE | `/posts/{id}/votes` | write | `?voteId=` or `?userId=` or `?email=` | | POST | `/posts/{id}/comments` | write | `{ body, parentId?, internal?, private?, author? }`. Without `author` the comment comes from the team | | GET | `/boards`, `/statuses`, `/tags` | read | | | GET | `/users` | read | `q`, `companyId`, `plan`, `sort=recent\|mrr\|name` | | POST | `/users/identify-batch` | write | Bulk upsert companies + users. Revenue signals on their voted posts are recomputed | | GET / PUT | `/users/{id}` | read / write | PUT upserts by your id | | GET | `/companies` | read | `q`, `plan`, `sort=mrr\|votes\|name\|recent` | | GET / PUT | `/companies/{id}` | read / write | With users and top requests. PUT upserts by your id | | GET | `/changelog` | read | `?state=draft\|scheduled\|published` | | POST | `/changelog` | write | Draft `{ title, body?, type?, postIds? }` | | GET | `/changelog/{id}` | read | | | POST | `/changelog/{id}/publish` | write | `{ notify?, shipStatus? }`. Ships linked requests and notifies their voters and followers | | GET | `/vote-lists` | read | [Vote lists](https://theyasked.me/docs/vote-lists) with the requests each shows and its `snippets` (`widget`, `inline`, `webview`) | | POST | `/vote-lists` | write | `{ key, board \| boardId (id, slug or name), title, eyebrow?, subtitle?, icon?, requestPlaceholder?, requestButtonLabel?, showCounts?, showStatus?, order?, itemOrder? (ids or slugs), hideShipped?, maxItems?, allowRequests? }` → 201 `{ list }`. Admin role (the key's creator), plan flag `voteLists`, 409 when the key exists | | GET / PATCH | `/vote-lists/{id}` | read / write | By id or key. PATCH is partial → `{ list, updated }` | | GET | `/insights/top-requests` | read | `by=demand\|revenue\|momentum\|opportunity`, `limit`, `days`, `plan`, `companyId`, `platform`, `board`, `statusCategory` | | GET | `/insights/summary` | read | `days` (default 30): KPIs vs previous period, follow-through, trending, top by revenue, needs attention | | GET | `/openapi.json` | none | OpenAPI 3.1 | `companies` in `identify-batch` are the workspace's groups, whatever the dashboard calls them (accounts, stores, families, classes…); field names stay `companies` / `companyId`. A family app sends `"companies": [{ "id": "fam_9", "name": "The Rivera family", "plan": "Family", "mrr": 12 }]` and each member with `"companyId": "fam_9"` and `"attributes": { "role": "parent" }` (or `"kid"`, no email). The family's $12 then counts once per request however many members vote. See [SSO.md](https://theyasked.me/docs/sso#grouping-users-b2b-and-b2c). Older server-to-server endpoints (PLAN §5) remain available with a `write` key: `POST /api/v1/identify-batch`, `POST /api/v1/posts` (`{ title, body?, board?, userId, externalId? }`) and `POST /api/v1/votes` (`{ postId, userId, importance?, note? }`). How the scores are defined (PLAN §4): - **Demand** = identified voters + 0.5 × anonymous voters + 2 × companies. - **Revenue** = MRR of each voting company counted once, plus the MRR of voters who have no company. - **Momentum** = votes in the last 14 days ÷ max(1, votes in the 14 days before that). - **Opportunity** = a 0–100 blend of the three above plus critical-importance votes. Segment filters (`plan`, `companyId`, `platform`, `days`) control **which votes count**. For example, `plan=Enterprise` ranks requests using only Enterprise voters. --- ## 3. Webhooks Admins manage webhooks in **Settings → Webhooks** (`/api/admin/webhooks`). Each webhook has a URL (https only, except `localhost` in dev), a format (`json`, `slack` or `discord`) and a list of events. An empty list means all events. ### Events | Event | Fired when | `data` | | --- | --- | --- | | `post.created` | a post is created from the portal, widget, API or dashboard (posts held for moderation are not announced) | `post`, `postId`, `title`, `boardId`, `source` | | `post.updated` | fields other than status change | `post`, `fields[]` | | `post.status_changed` | status changes (single or bulk) | `post`, `fromStatusId`, `toStatusId`, `statusName`, `category`, `note`, `notified` | | `post.merged` | a post is merged into another | `post` (target), `sourcePost`, `votesMoved` | | `post.deleted` | a post is deleted | `postId`, `title`, `slug` | | `vote.created` | a new vote (end-user, on behalf, API) | `post`, `endUserId`, `importance` | | `vote.deleted` | a vote is removed | `post`, `endUserId` | | `comment.created` | a public comment / staff reply (not internal notes or private comments) | `post`, `commentId`, `body` | | `changelog.published` | a changelog entry is published (incl. scheduled) | `changelogId`, `title`, `linkedPostIds`, `shippedPostIds`, `notified` | | `user.identified` | first SSO identification of a user, or an API batch sync | `endUser` / `users[]` | `post` is `{ id, slug, title, boardId?, statusId?, url, dashboardUrl }`. ### JSON payload and headers ```http POST /your/endpoint Content-Type: application/json User-Agent: TheyAsked-Webhooks/1 (+https://theyasked.me/docs/webhooks) TheyAsked-Event: post.status_changed TheyAsked-Delivery: whd_8Kq2… (stable across retries — use it for idempotency) TheyAsked-Attempt: 1 TheyAsked-Signature: t=1791935156,v1=5f2c… { "id": "whd_8Kq2…", "event": "post.status_changed", "createdAt": "2026-10-14T02:25:56.000Z", "workspace": { "id": "ws_…", "slug": "acme", "name": "Acme" }, "url": "https://theyasked.me/acme/p/export-rewards-to-csv", "data": { "post": { … }, "statusName": "Planned", "note": "Targeting May", "notified": 14 } } ``` `slack` webhooks receive Block Kit (`{ text, blocks }`): a summary with a link, fields, and context. Use a Slack Incoming Webhook URL. `discord` webhooks receive `{ embeds: [{ title, url, description, color, fields, footer, timestamp }] }`. ### Verifying signatures `v1 = hex(HMAC-SHA256(secret, "."))`. Use the secret you got when you created the webhook (`whsec_…`; you can rotate it with `POST /api/admin/webhooks/:id/rotate-secret`). Reject timestamps more than 5 minutes old, and compare in constant time. **Node.js** ```js import crypto from 'node:crypto'; export function verifyTheyAskedSignature(rawBody, header, secret, toleranceSec = 300) { const parts = Object.fromEntries(header.split(',').map((p) => p.split('='))); const t = Number(parts.t); if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false; const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex'); const a = Buffer.from(expected, 'hex'); const b = Buffer.from(parts.v1 ?? '', 'hex'); return a.length === b.length && crypto.timingSafeEqual(a, b); } // Express: use the RAW body, not the parsed JSON. app.post('/theyasked', express.raw({ type: 'application/json' }), (req, res) => { if (!verifyTheyAskedSignature(req.body.toString('utf8'), req.get('TheyAsked-Signature'), process.env.THEYASKED_WEBHOOK_SECRET)) { return res.sendStatus(400); } const event = JSON.parse(req.body); // dedupe on event.id / TheyAsked-Delivery, then handle event.event … res.sendStatus(204); }); ``` **Python** ```python import hashlib, hmac, time def verify_theyasked_signature(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool: parts = dict(p.split("=", 1) for p in header.split(",")) t = int(parts.get("t", "0")) if not t or abs(time.time() - t) > tolerance: return False expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, parts.get("v1", "")) # Flask @app.post("/theyasked") def theyasked_webhook(): if not verify_theyasked_signature(request.get_data(), request.headers["TheyAsked-Signature"], THEYASKED_WEBHOOK_SECRET): abort(400) event = request.get_json() return "", 204 ``` ### Delivery, retries, auto-disable - Deliveries are sent after the response (`waitUntil`) with a 5 s timeout. Any 2xx counts as success. Redirects are not followed. - Every attempt is recorded in the delivery log: `GET /api/admin/webhooks/:id/deliveries`, plus a detail endpoint that returns the rendered payload. `POST …/deliveries/:deliveryId/redeliver` sends one again. - Failed deliveries are retried by the `*/15 * * * *` cron with backoff of 5 min, 30 min, 2 h, 8 h and 24 h. That is 6 attempts over about 1.5 days, at most 20 per cron run (to fit the Free plan's 50 subrequests). - After **10 consecutive failures** the webhook is switched off (`active: false`). Turning it back on (`PATCH { active: true }`) resets the failure count. Pending retries for a disabled or deleted webhook are abandoned. - `POST /api/admin/webhooks/:id/test` sends a signed sample `post.created`. Test sends are logged but never retried and don't count toward auto-disable. --- ## 4. MCP server (Claude and other MCP clients) Endpoint: `https://theyasked.me/mcp`, using Streamable HTTP. It is stateless (no Durable Objects) and runs on Workers Free. It is built on the official MCP TypeScript SDK v2 (`@modelcontextprotocol/server`, `createMcpHandler`), which serves the **2026-07-28** protocol revision and falls back to stateless serving for 2025-era clients (`2025-06-18`, `2025-11-25`). Responses are plain JSON, with no SSE. Two endpoints, same server: **`/mcp`** (tools filtered by the caller's scopes) and **`/mcp/readonly`** (read tools only, whatever the key or token; an OAuth token issued for `/mcp` works there too because it is a sub-path of the resource). ### Tools (36: 18 read, 18 write) Every result carries `structuredContent` that matches the tool's `outputSchema` (MCP spec 2025-06-18+; schemas in `packages/shared/src/schemas/mcp.ts`) plus a markdown text block for the model and for text-only hosts. Both include ids and dashboard links (`url`). Hints: **R** readOnly, **D** destructive (requires `confirm: true`), **I** idempotent, **O** openWorld (may notify customers). Every tool declares all four hints and a `title`. | Tool | Scope | Hints | What it does | | --- | --- | --- | --- | | `search_posts` | posts:read | R I | Full-text search plus filters (board, status, category, tag, voter plan / company), sort by votes / mrr / trend / newest / activity | | `get_post` | posts:read | R I | Body, status + history, tags, impact/effort, internal note, comments (incl. internal), top 15 voters, segments | | `find_similar_posts` | posts:read | R I | Text → ranked matches (embeddings + cosine; full-text fallback scored by title-word overlap, 0–1). `excludePost` / `email` enable merge / vote buttons in the view | | `get_roadmap` | posts:read | R I | Roadmap columns (statuses shown on the roadmap) with count, revenue at stake and top requests | | `list_tags` | posts:read | R I | Tags with post counts | | `list_vote_lists` | posts:read | R I | [Vote lists](https://theyasked.me/docs/vote-lists) (`key?`): copy, board, order, the requests shown now (votes, status, summary) and how to embed each (WebView URL, widget snippet, inline element) | | `list_boards_statuses_tags` | posts:read | R I | Valid boards / statuses (with category) / tags | | `top_requests` | insights:read | R I | Rank by demand / revenue / momentum / opportunity, for a segment (plan, company, platform) and window (`days`) | | `insights_summary` | insights:read | R I | KPIs vs previous period, follow-through, trending, top by revenue, needs attention | | `activity_feed` | insights:read | R I | Recent events (new requests, votes with company/plan/MRR, removed votes, comments, status changes, merges, published changelog), filter by type / post, `days` or `since`, counts per type, paginated | | `render_chart` | insights:read | R I | MCP only. Bar / column / line / stacked / scatter chart from a validated spec of numbers Claude already has | | `list_companies` / `get_company` | people:read | R I | Customers with plan, MRR, users, votes; a company's users and requests | | `who_asked_for` | people:read | R I | Voters, companies, plans with revenue, platforms, importance, notes for one request | | `list_voters` | people:read | R I | Every voter of a request (highest MRR first) with company, plan, importance, note, date; cursor-paginated | | `list_users` / `get_user` | people:read | R I | Identified customers (search, company, plan, sort); one customer with attributes and votes | | `list_changelog` | changelog:read | R I | Entries with state and the requests they ship | | `create_post` | posts:write | – | On behalf of a customer (email / your user id, optional name and `company` {id, name, plan, mrr} upserted first), board, status, tags; `idempotencyKey` makes retries return the same post; returns similar requests and a duplicate warning (score ≥ 0.85); `icon` (`{ kind: 'domain' \| 'emoji' \| 'upload', value }`) and `summary` set its vote-list card | | `update_post` | posts:write | I | Title, body, board, tags (replace), owner (teammate email / id), ETA, impact, effort, priority, team-only; also `icon` / `summary` (vote-list logo tile and one-liner; `null` clears) | | `update_post_status` | posts:write | I O | Change status (by id, slug or name). Notifies followers unless `notify: false` | | `merge_posts` | posts:write | D | Merge a duplicate into the surviving post; `confirm: true` required | | `vote_on_behalf` | votes:write | I | Vote for a customer (email / user id, created if new), importance, note; voting twice keeps one vote | | `remove_vote` | votes:write | D I | Remove a customer's vote (email / user id / vote id); `confirm: true` required | | `add_comment` | comments:write | O | `visibility` public / internal / private, `replyTo`, as the team or as a customer; **`notify` defaults to false** | | `add_internal_note` | comments:write | – | Staff-only note on a post | | `create_tag` | tags:write | I | Case-insensitive dedupe: returns the existing tag instead of a duplicate | | `tag_post` / `untag_post` | tags:write | I | Add (optionally creating) / remove tags on a request | | `create_changelog_draft` | changelog:write | – | Draft entry linked to shipped requests | | `update_changelog` | changelog:write | I | Edit a draft / scheduled entry (title, body, type, shipped requests, schedule) | | `publish_changelog` | changelog:write | O | Publish now and ship the linked requests; **`notify` defaults to false**, with `notify: true` the result says how many people were notified. Needs the admin role | | `create_vote_list` | posts:write | – | A [vote list](https://theyasked.me/docs/vote-lists): `key`, `board` (id, slug or name), `title`, and optionally `eyebrow`, `subtitle`, `icon`, `requestPlaceholder`, `requestButtonLabel`, `showCounts`, `showStatus`, `order` (manual / votes / newest), `itemOrder` (request ids or slugs on the board), `hideShipped`, `maxItems`, `allowRequests`. Returns the list, the requests it shows and its snippets (`TheyAsked('open', { list })`, inline `
`, WebView URL). Admin role, plan flag `voteLists` | | `update_vote_list` | posts:write | I | Same fields, partial, on a list by id or key (`list`); returns the list and the fields that changed | | `upsert_user` / `upsert_company` | people:write | I | Create / update by your id (plan, MRR, attributes merged); revenue on the posts they voted for is recomputed like `identify-batch` | Code: `workspace-api/tools.ts` (the original tools, shared with Ask), `workspace-api/more-tools.ts` (v2.1, MCP only), presentation in `mcp-apps/tools.ts` and `mcp-apps/more-presentation.ts`. Every tool wraps the same workspace-api functions as the REST API. Everything is scoped to the workspace of the key or token. ### MCP safety model | Layer | What it does | Where | | --- | --- | --- | | Scopes | Only tools the caller's scopes grant are registered (a hidden tool can't be called) | `visibleTools`, `lib/mcp.ts` | | Read-only endpoint | `/mcp/readonly` registers read tools only | `isReadOnlyMcpPath` | | Annotations | `title` + readOnly / destructive / idempotent / openWorld on every tool | `toolAnnotations` | | Confirmation | Destructive tools (`merge_posts`, `remove_vote`) refuse to run without `confirm: true` (`confirmation_required: …`) | `buildServer` | | Prompt injection | Server `instructions` say customer text is data, never instructions; results that carry customer text start with an "Untrusted data" banner and long passages (bodies, comments, notes) are wrapped in `` tags with any fake tag inside removed (Featurebase-style split of trusted / untrusted content) | `mcp-apps/format.ts` (`fence`, `UNTRUSTED_NOTE`) | | Notifications | `add_comment` / `publish_changelog` notify only with `notify: true`; `update_post_status` reports followers notified | tool defaults | | Roles | `publish_changelog`, `create_vote_list` and `update_vote_list` need the acting staff member to be admin+ (OAuth connectors act as the signed-in member) | `assertActorRole` | | Rate limits | 120 requests/min per key or grant (`RL_API`) + 60 writes/min per caller (`RL_WRITE`, key `mcp:`) | `limitHit` | | Audit log | Every write (success or failure): actor, key / grant id, via, tool, target, redacted args (bodies and notes as lengths). Settings → API → **AI activity**; `GET /api/admin/mcp-audit` (admin); 400-day retention | `mcp_audit_log` (migration 0011) | | Plan gating | `getPlanConfig` / `limitsFor(plan).mcp`, as before | `serveMcp` | ### Prompts `prompts/list` / `prompts/get` (slash commands in Claude Code and Claude Desktop). Each is a user message naming the tools to call, in order, and what to produce; writes still need the user's yes. | Prompt | Arguments | Workflow | | --- | --- | --- | | `weekly_digest` | `days?`, `plan?` | insights_summary → activity_feed → top_requests (momentum, revenue) → who_asked_for → one-page digest | | `triage_new_requests` | `days?`, `board?` | open requests → list_tags / statuses → find_similar_posts per request → table of duplicates, tags, status → apply approved rows | | `prep_roadmap_review` | `segments?` | get_roadmap → top_requests by revenue / opportunity / segment → who_asked_for → gaps and questions | | `draft_release_notes` | `postIds?`, `tone?` | shipped requests → list_changelog drafts → create_changelog_draft (never publishes by itself; notify false) | | `account_brief` | `company` | get_company → top_requests for the company → who_asked_for / get_post → list_users → brief | ### Resources - `theyasked://workspace/summary` (JSON): name, plan, counts, boards, statuses, tags. - `theyasked://boards` (JSON): boards with request counts. - `ui://theyasked/*.html`: the MCP Apps views below. Data resources need `posts:read`. ### Discovery and listings `/.well-known/mcp/server-card.json` (alias `/.well-known/mcp.json`) describes the server (endpoints, OAuth metadata, tools with scopes and hints, prompts, resources). `server.json` at the repo root is the official MCP registry entry (`me.theyasked/theyasked`). `/.well-known/mcp-registry-auth` serves the registry's HTTP domain proof from the optional `MCP_REGISTRY_AUTH` var. Submission steps: [MCP-LISTING.md](./MCP-LISTING.md). Public docs page: `/docs/mcp` ([MCP.md](https://theyasked.me/docs/mcp)). ### Rich results in Claude (MCP Apps) In hosts that support **MCP Apps** (the official MCP UI extension, `io.modelcontextprotocol/ui`, spec 2026-01-26, standardized from MCP-UI and the OpenAI Apps SDK), TheyAsked answers with interactive views instead of only text: ranked bars, KPI cards, segment breakdowns, request and account cards, and charts. The idea is the same as generative UI (thesys.dev): the model picks the tool, the result comes back as components you can act on, with follow-up actions. | Tool | View (`ui://theyasked/…`) | What you see | | --- | --- | --- | | `find_similar_posts` | `similar.html` | Ranked matches with a similarity meter and status. **Add vote** (when `email` was passed and the connection has `votes:write`) and **Merge into** (when `excludePost` was passed and it has `posts:write`) ask for an inline confirmation, then call `vote_on_behalf` / `merge_posts` (`confirm: true`) through the host | | `create_post` | `created.html` | The request just logged (who, company, status, tags, body), the duplicate warning, and possible duplicates with a confirmed **Merge into** | | `top_requests` | `requests.html` | Ranked ledger, voters / revenue bar toggle, 8-week sparkline, momentum, status pills. **Who asked?** expands a plan-mix + top-companies panel (calls `who_asked_for` through the host). **Mark planned** (write scope only, open/under-review rows) asks for an inline confirmation, then calls `update_post_status` through the host | | `insights_summary` | `insights.html` | Six KPI cards (value, signed delta, was N, sparkline over the period), follow-through facts, what's moving, needs attention | | `who_asked_for` | `who-asked.html` | Voters / companies / revenue, plan mix (ordinal ink ramp, revenue or voters), companies with MRR bars, platform split, importance chips, voter quotes | | `get_post` | `request.html` | Request card: status, tags, votes, companies, revenue, body as plain text, revenue by plan, timeline, latest comments (internal marked), Who asked? / Mark planned | | `list_companies` / `get_company` | `companies.html` / `company.html` | Accounts by MRR (expand "What they asked" via `get_company`); account card with attributes and what the account asked for | | `search_posts` | `posts.html` | Matching requests with votes, companies, revenue | | `render_chart` | `chart.html` | Bar, column, line, stacked or scatter chart with legend, Chart/Table toggle, source line | Every view ends with one or two **Ask next** chips that send a follow-up message through the host (`ui/message`). Links ("open in TheyAsked") go through `ui/open-link`, so the host shows its own confirmation. **Which hosts render it (Oct 2026).** claude.ai (web) and Claude Desktop render MCP Apps for remote connectors; the Claude mobile apps render them in a native WebView; Claude Code and the MCP inspector are text-only and show the markdown fallback (tables, `▁▂▃▅▇` sparklines, `███▌` bars). Other MCP Apps hosts (ChatGPT, VS Code, Goose, Postman, MCPJam) use the same resources. Claude asks for permission the first time it displays an app. Nothing changes in how you connect: add the connector as described below, then ask, for example, *"What are Enterprise customers asking for by revenue? Chart it."* **How it is wired (spec details).** - Tools link to a view with `_meta.ui.resourceUri` (`ui://theyasked/.html`) and `_meta.ui.visibility: ["model", "app"]`, plus the legacy flat `_meta["ui/resourceUri"]` key that the ext-apps `registerAppTool` helper also writes. Hosts without MCP Apps ignore `_meta`. - `resources/list` and `resources/read` serve the views with mime type `text/html;profile=mcp-app` and resource `_meta.ui = { csp: { connectDomains: [], resourceDomains: ["https://assets.claude.ai"] }, prefersBorder: false }` (no network; the one origin is Claude's host-font CDN for `styles.css.fonts`). We do not set `ui.domain`, so Claude derives a per-conversation sandbox origin. - All views are **one** self-contained document (`packages/mcp-ui`, vanilla TS + CSS, bundled by esbuild into `generated/app-html.ts`, **55 KB raw / 17.6 KB gzip**, budget 60 KB enforced by the build). The worker bakes the view name into ``. Rebuild with `pnpm --filter @loop/mcp-ui build`; its test run fails if the generated file is stale. - The view speaks the MCP Apps bridge over `postMessage` (JSON-RPC 2.0): `ui/initialize` (protocol `2026-01-26`, `appCapabilities.availableDisplayModes: ["inline"]`) → `ui/notifications/initialized`; it renders from `ui/notifications/tool-result` (`structuredContent`), applies `hostContext.theme`, `styles.variables` (Claude's `--color-*`, `--font-*` tokens) and `styles.css.fonts`, follows `host-context-changed`, reports its height with `ui/notifications/size-changed` (no nested scrolling), and answers `ping` / `ui/resource-teardown`. The bridge is hand-rolled (`packages/mcp-ui/src/bridge.ts`, about 2 KB) instead of the ext-apps `App` class to stay small and dependency-free. - Tool results for views carry result `_meta["theyasked/canWrite"]` (may call `update_post_status`) and `_meta["theyasked/writeTools"]` (every write tool the connection may call; empty on `/mcp/readonly`), so write buttons only appear when the matching tool is allowed. Bundle budget: 60 KB (59.2 KB with the v2.1 views). - Design: the Ledger language (DESIGN.md) with a transparent background; text, rules and washes follow the host's style variables with Ledger fallbacks, vermilion stays the one signal colour, status pills keep the progress-pie glyph. Light/dark come from the host. Responsive from 320 px; touch targets grow to 44 px on coarse pointers. **Security.** Each view has its own CSP meta (`default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; font-src https://assets.claude.ai data:; connect-src 'none'; base-uri 'none'; form-action 'none'`) on top of the host's sandbox CSP. No remote scripts, no fetches, no credentials in the view: every action is a host `tools/call` that runs under the user's own connection and scopes, so `update_post_status` still needs `write` and the host may ask the user to approve it. All tool data is escaped by a safe-by-default `html` template (`packages/mcp-ui/src/html.ts`); only `http(s)` links are rendered. Tests cover hostile titles, notes, attributes and `javascript:` URLs. **Text fallback.** For example, `top_requests` in Claude Code: ```markdown **Top 5 requests by revenue at stake (MRR of voting companies)** · all customers | # | Request | Status | Voters | Revenue/mo | Momentum | 8 wk | | ---: | --- | --- | ---: | ---: | ---: | --- | | 1 | [Push notification when points expire](…/app/posts/pst_00026NHtYn8pz5ol) `pst_00026NHtYn8pz5ol` | Open | 71 | $22.7k ████████ | ↑ 1.9× | ▁▁▂▃▄▅▇█ | | 2 | [Referral codes customers can share on WhatsApp](…) `pst_0004ew5A9cJ7iQU8` | Under review | 50 | $22.6k ███████ | ↑ 1.5× | ▂▃▂▅▅▂▁█ | ``` **Screenshots** (dev host harness, Claude's style variables, chat width 720 px and narrow 380 px, light and dark): `docs/v2/screenshots/mcp/` — `top-requests-*`, `top-requests-expanded-*` (Who asked? panel + Mark planned confirmation), `insights-*`, `who-asked-*`, `request-*`, `companies-*`, `company-*`, `search-*`, `chart-bar-*`, `chart-line-*`, `chart-scatter-*`. ![Top requests in Claude, light](screenshots/mcp/top-requests-light-720.png) ![Who asked, dark](screenshots/mcp/who-asked-dark-720.png) **Try it locally (dev host harness).** `apps/worker/dev/mcp-host.html` is a minimal MCP Apps host: it connects to `/mcp`, calls a tool, renders the linked `ui://` resource in a sandboxed iframe (`sandbox="allow-scripts"`) and implements the host side of the bridge (initialize with Claude's style variables, tool-input / tool-result, size changes, proxied `tools/call`, open-link and message logging). ```bash THEYASKED_KEY=ta_live_... MCP_URL=http://localhost:8788/mcp node apps/worker/dev/mcp-host.mjs open "http://localhost:8790/?tool=top_requests&args={\"by\":\"revenue\"}&theme=dark&width=380" node apps/worker/dev/mcp-screens.mjs # all views × light/dark × 720/380 → docs/v2/screenshots/mcp/ ``` Sources: MCP Apps spec 2026-01-26 and SDK (github.com/modelcontextprotocol/ext-apps, `specification/2026-01-26/apps.mdx`, `src/spec.types.ts`, `src/server`); Claude docs "Get started with MCP Apps", "Design guidelines", "Blend your MCP App with Claude's theme", "Open external links from MCP Apps", "Supersede older widget instances" (claude.com/docs/connectors/building/mcp-apps/…); Claude "Get started with connectors" (where MCP Apps render); MCP spec 2025-06-18 tools (`outputSchema`, `structuredContent`); thesys.dev (generative UI: charts, cards, follow-ups). **Caveats.** Hosts mount a new iframe per tool call and keep older ones live; we don't run the BroadcastChannel supersession pattern because our views are read-mostly snapshots. Custom (non directory) connectors always get Claude's "Open external link" confirmation. Display mode is inline only (no fullscreen yet). The view reads `structuredContent`; a host that drops it shows a short "no structured data" notice and the model still has the markdown. ### Claude Code ```bash claude mcp add --transport http theyasked https://theyasked.me/mcp \ --header "Authorization: Bearer ta_live_..." ``` Use a `read`-only key if you only want questions answered. Try: *"Using theyasked, what are our Enterprise customers asking for most by revenue? Then summarize who asked for the top one."* ### Claude Desktop **Settings → Connectors → Add custom connector** with the URL `https://theyasked.me/mcp`. This uses the OAuth flow below, so you don't need a key. For a config-file setup with an API key, use `claude_desktop_config.json` with the `mcp-remote` bridge: ```json { "mcpServers": { "theyasked": { "command": "npx", "args": ["-y", "mcp-remote", "https://theyasked.me/mcp", "--header", "Authorization:Bearer ${THEYASKED_KEY}"], "env": { "THEYASKED_KEY": "ta_live_..." } } } } ``` ### claude.ai custom connector (OAuth 2.1) 1. In claude.ai, open **Settings → Connectors → Add custom connector**. Name it "TheyAsked" and set the URL to `https://theyasked.me/mcp`. 2. claude.ai discovers the OAuth metadata (`/.well-known/oauth-protected-resource/mcp` → `/.well-known/oauth-authorization-server`). It registers itself through Dynamic Client Registration (`/oauth/register`) and opens `/oauth/authorize`. 3. TheyAsked's consent page reuses your dashboard session. If you're not signed in, it shows a sign-in form. Pick the **workspace** and the access level: **Read**, and optionally **Write**. Then click **Allow access**. 4. Tokens are issued by `/oauth/token` with PKCE. Access tokens last 1 h; refresh tokens last 60 days. Grants are bound to you and the workspace. If you are removed from the workspace, the connector stops working on its next call. Under the hood, OAuth uses `@cloudflare/workers-oauth-provider` with the `OAUTH_KV` namespace. Tokens and secrets are stored only as hashes, and grant props are encrypted. Without `OAUTH_KV`, `/mcp` accepts API keys only. ### Verifying locally ```bash pnpm db:reset:local && cd apps/worker && npx wrangler dev --port 8788 --var APP_URL:http://localhost:8788 # Sign in as admin@theyasked.test / password123, create a key under Settings → API (or POST /api/admin/api-keys), then: npx @modelcontextprotocol/inspector --cli http://localhost:8788/mcp --transport http \ --header "Authorization: Bearer ta_live_..." --method tools/call --tool-name top_requests --tool-arg by=revenue ``` --- ============================================================================== ============================================================================== # Claude & MCP TheyAsked ships a remote [Model Context Protocol](https://modelcontextprotocol.io) server. Connect Claude (claude.ai, Claude Desktop, Claude Code) or any MCP client to one workspace and ask questions like "what do Enterprise customers want most, by revenue?", log a request from a sales call, add a customer's vote, reply, and publish release notes, without leaving the conversation. - **Server URL:** `https://theyasked.me/mcp` (Streamable HTTP) - **Read-only URL:** `https://theyasked.me/mcp/readonly` (never exposes a tool that changes data) - **Plans:** Pro and above. ## Connect **claude.ai and Claude Desktop.** Settings → Connectors → Add custom connector, name it TheyAsked, URL `https://theyasked.me/mcp`. Sign in to TheyAsked, pick the workspace, choose what Claude may do (see Permissions), then Allow access. Tokens last an hour and refresh for 60 days; removing someone from the workspace revokes their connector at once. **Claude Code.** Create an API key in Settings → API & MCP, then: ```bash claude mcp add --transport http theyasked https://theyasked.me/mcp \ --header "Authorization: Bearer ta_live_…" ``` **Other clients.** Any client that speaks Streamable HTTP with OAuth 2.1 (dynamic client registration, PKCE) or a static `Authorization: Bearer` header works. `npx mcp-remote https://theyasked.me/mcp` bridges stdio-only clients. ## Permissions Every connection carries scopes. The OAuth consent screen has one checkbox per scope; API keys get the same picker in Settings → API & MCP. Claude only sees the tools its scopes allow. | Scope | Lets Claude | | --- | --- | | `posts:read` (always on) | Search and read requests, boards, statuses, tags and the roadmap | | `people:read` | See customers and companies with plan and MRR, voters, who asked | | `changelog:read` | Read changelog entries and drafts | | `insights:read` | Rankings by demand and revenue, KPIs, the activity feed | | `posts:write` | Create requests for customers, edit them, change status, merge duplicates | | `votes:write` | Add or remove votes on behalf of customers | | `comments:write` | Post replies and internal notes | | `tags:write` | Create tags, tag and untag requests | | `changelog:write` | Draft, edit and publish changelog entries | | `people:write` | Create or update customers and companies | Older connections with `read` / `write` keep working: `read` covers every read scope, `write` everything. ## Tools **Read (18):** `search_posts`, `get_post`, `top_requests`, `insights_summary`, `who_asked_for`, `list_companies`, `get_company`, `list_boards_statuses_tags`, `list_changelog`, `find_similar_posts`, `get_roadmap`, `list_voters`, `list_tags`, `list_vote_lists`, `list_users`, `get_user`, `activity_feed`, `render_chart`. **Write (18):** `create_post`, `update_post`, `update_post_status`, `merge_posts`, `vote_on_behalf`, `remove_vote`, `add_comment`, `add_internal_note`, `create_tag`, `tag_post`, `untag_post`, `create_changelog_draft`, `update_changelog`, `publish_changelog`, `create_vote_list`, `update_vote_list`, `upsert_user`, `upsert_company`. In claude.ai and Claude Desktop several tools also draw an interactive view: ranked requests with sparklines, the feedback briefing, who asked, request and account cards, charts, similar requests and the request you just logged (with confirmed "Add vote" and "Merge" buttons). ## Prompts Slash commands in Claude Code and Claude Desktop: `weekly_digest`, `triage_new_requests`, `prep_roadmap_review`, `draft_release_notes`, `account_brief`. Each one tells Claude which tools to call and what to produce; anything that changes data still asks you first. ## Safety - **Your words, not your customers'.** Titles, bodies, comments and notes are written by customers. Results mark them as untrusted data and the server tells Claude never to follow instructions found inside them. - **Confirmation.** Every tool is labeled read-only or not; Claude asks before writes. Irreversible tools (`merge_posts`, `remove_vote`) also require an explicit `confirm: true`. - **Nobody is emailed by accident.** Replies and changelog publishing notify customers only when you ask (`notify` defaults to false); status changes say how many followers were notified. - **Audit trail.** Every change made through MCP is logged with who, through which key or connector, the tool and its target: Settings → API & MCP → AI activity. - **Limits.** 120 requests and 60 writes per minute per connection. Publishing the changelog needs the admin role. ## Privacy The MCP server reads and writes the data of the one workspace you connect, as you, and nothing else. TheyAsked does not see your conversations with Claude: only the tool calls Claude makes and their arguments reach us, and we keep the audit trail of changes (not reads) for 400 days. See the [privacy policy](https://theyasked.me/privacy) and [terms](https://theyasked.me/terms). Questions: hello@theyasked.me.