Help & Feedback module
The Help module turns any page running the Spotlight SDK into a two-way support channel — your users ask, your team replies, the context they were looking at is captured automatically.
This guide covers integration; the architecture lives in RFC-026.
What it gives you
- Floating launcher in the corner of every SDK-instrumented page (or embedded inline if you prefer). Branded with the tenant accent.
- Composer with quick-answer FAQs matched to the user's current page. Self-serve first; a free-text composer second.
- Context picking — the user clicks any element on the page to add it to their question. Picks carry the structured
data-ui-name/ entity / state context (per the UI Instrumentation Pack) so support sees what the user clicked, not a screenshot. - Rich client context — page route, viewport, locale, PostHog session id and active feature flags, plus anything your host page provides via
contextProviders. Grouped + rendered as a Context map in the operator inbox. - Real-time threads — when a thread is open on either side, both client and dashboard fast-poll every 2–3 s. Typing indicators
- author avatars on each bubble.
- Email fallbacks — operators get an email when a request lands; users get an email when an operator replies.
- Operator workflow — assign / unassign tickets, mark resolved / re-open, fire canned acknowledgements, and promote great answers into FAQ articles so the next user self-serves.
Quickstart
<script
src="https://your-spotlight-host/sdk/latest/spotlight.min.js"
data-api-key="sk_test_…"
async
></script>
<script>
window.addEventListener('DOMContentLoaded', () => {
Spotlight.init({
apiKey: 'sk_test_…',
baseUrl: 'https://your-spotlight-host/api',
autoStart: true,
help: {
// Floating bottom-right by default. Use 'embedded' + a
// `<div data-spotlight-help-mount>` placeholder on the
// page if you want it inline.
mount: 'floating',
position: 'bottom-right',
label: 'Need help?',
// Quick-prompt pills shown above the textarea.
quickPrompts: ['How do I…', "This doesn't work", 'Suggestion'],
// Optional context providers — return objects that get
// merged into every submission's `client_context`.
contextProviders: [
() => ({
host: {
tenant_id: window.MyApp?.user?.tenant_id,
merchant_id: window.MyApp?.user?.merchant_id,
role: window.MyApp?.user?.role,
platform_version: window.MyApp?.version,
},
}),
],
},
});
});
</script>That's the whole integration. The SDK handles auth via the existing JWT/identity layer it already uses for tours.
Enabling per project
Help is off by default on every new project — flip the toggle under Dashboard → Projects → {your project} → Help widget to turn it on. The dashboard stores the toggle in the project's help_config.enabled field and ships it down via /v1/sdk/config, so no SDK init change is needed.
The host page can override locally (handy when a dev wants to test the widget without flipping the project setting):
Spotlight.init({
help: {
enabled: true, // overrides the project default
},
});For per-route disabling, gate the init call yourself based on location.pathname.
Embedded mount
Drop a placeholder anywhere on the page:
<div data-spotlight-help-mount></div>The launcher renders inside it instead of floating in the corner. Useful for in-app help drawers, sidebar slots, or marketing pages.
Context providers — the most powerful hook
Every help submission carries a client_context object. The SDK populates the browser and posthog groups automatically; the host page contributes more via either of these mechanisms:
Init-time provider (preferred)
Spotlight.init({
help: {
contextProviders: [
() => ({
host: {
tenant_id: currentTenantId(),
jwt_subject: currentJwtSub(),
plan: currentPlan(),
},
}),
],
},
});A provider runs once per submit (no global subscription). It returns a plain object that merges into client_context. Throw or return non-object → silently ignored, never breaks the submit.
Runtime escape hatch
For surfaces that can't reach the SDK init (e.g. an iframe), set the global directly:
window.__SPOTLIGHT_HELP_CONTEXT = {
account_id: 'acc_123',
experiment_bucket: 'B',
};Read on every submit. Same merge rules.
What lands where
The operator's Context map in the dashboard groups all this by source: page, browser, posthog, host, plus any custom group you nest yourself. Operators see exactly what the user could see, no back-and-forth required.
What NOT to put in here
- Anything the user shouldn't be able to inspect via DevTools. The payload travels client-side; treat it as user-visible.
- PII you wouldn't put in
data-ui-*attributes (see Instrumentation Pack §13.4). Pass IDs the operator can resolve server-side; never raw emails, account numbers, or balances.
FAQ articles
Operators curate articles at /dashboard/help/articles. Each article has:
| Field | Meaning |
|---|---|
| Title | What the user clicks in the composer's "Quick answers" section. |
| Body | Plain text + basic markdown (**bold**, paragraphs, lists). |
| Routes | Optional URL patterns — e.g. /customers, /orders/*. Empty = always. |
| Tags | Free-form metadata for ordering and future search. |
The composer matches articles by location.pathname; endsWith
includesmatching covers host pages mounted under a base path without operator config.
Promote-to-article workflow
Inside the inbox, every operator reply has a "Promote to FAQ →" button. Click it → name the article, optionally add routes, hit Save. The reply becomes a public FAQ article from the next page load onward. Self-serve answers compound automatically.
Real-time + typing
Both sides poll the open thread every 2–3 seconds. Pure HTTP for v1; SSE/WebSocket can swap in behind the same constants without UI changes.
The composer pings a typing endpoint at most every 2.5 seconds while the user is typing. The other side polls and shows a three-dot indicator if the timestamp is < 6 seconds old.
Ticket assignment
Inside a thread the operator clicks Take this ticket to assign it to themselves; their name + avatar appear on the card and inside the user-side widget ("Jane is helping you"). Click the × on the chip to unassign — the ticket returns to the shared queue.
There's no "round robin" or auto-assign in v1. Operators self-pick.
Email fan-out
The platform uses Resend and inherits the tenant's email_from setting (configure in the SDK's Settings → Email panel, or the dashboard's tenant settings):
| Trigger | Recipients |
|---|---|
| New request | All tenant admins (Admin.Users at admin/owner role). |
| User reply | All tenant admins. |
| Operator reply | The user's email (if captured via JWT claims). |
Email is best-effort — a Resend outage never blocks a help-thread submit.
Multi-agent
Any tenant Admin.Users row at admin or owner role can reply, assign, mark resolved, or promote articles. Add agents from /dashboard/members the same way you invite teammates today.
The operator's display name + email come from their Clerk profile, which is what shows up on the chat bubbles, the assignee chip, and the email-from line in user notifications.
Project scoping
Help requests are project-aware end-to-end. The SDK tags every request with the project_id the API key was minted under (empty when the key is tenant-wide). The operator inbox respects two layers of scoping:
- Per-admin allowlist — every Admin.Users row has a
project_idsset. Empty = tenant-wide (sees every project's inbox); non-empty = restricted to those projects. Owners always see everything regardless. Set per-member access from /dashboard/members → Edit → Project access. - Per-view filter — the inbox shows a pill row with the projects the operator can access plus a "Tenant-wide" pill for un-scoped threads. Clicking one filters the list (sent via
?project_id=on the API call). Selection persists across refreshes via localStorage.
The same project filter applies to the stats view, so a team's MTTR / CSAT numbers reflect only the projects they own.
Triage
Every thread carries a priority (low/normal/high/urgent, default normal) and a free-form tags array (max 8 × 40 chars, deduped case-insensitively). Set them inline from the thread header — single click for priority, type-and-Enter for tags. They flow into the stats endpoint as by_priority and by_tag buckets so dashboards graph "% urgent" or "MTTR per tag" without extra plumbing.
Per-thread rating (CSAT)
When the operator marks a thread resolved, the user's widget renders a 5-star prompt + optional comment. The rating is captured on POST /v1/sdk/help/requests/{id}/rating and flows into:
- The inbox card — resolved threads show their star rating (with the comment as the tooltip).
- The stats panel —
csat_percent(% of ratings that were 4 or 5),avg_rating, distribution across 1-5. - The leaderboard — per-operator
avg_rating+rated_countalongside MTTR and reply counts.
A user who hits the wrong star can re-rate; the latest score wins. The comment is optional and capped at 1000 chars.
Stats
Every help-desk metric lives behind one endpoint:
GET /v1/admin/help/stats?days=30&project_id=Returns:
| Field | What |
|---|---|
total/open/awaiting_user/resolved | Volume counts over the window. |
by_priority / by_tag | Bucket sums driving the triage roll-ups. |
avg_first_response_seconds | MTTFR — mean time to the first operator reply. |
avg_resolution_seconds | MTTR — mean time from create → resolved. |
p90_resolution_seconds | Slowest 10% — outlier-aware SLA metric. |
csat_percent / avg_rating / rating_distribution | CSAT roll-up (see above). |
operators[] | Per-operator leaderboard: replied, resolved, MTTFR, MTTR, avg_rating. |
Computed in-memory off the visible request list — no GSI changes needed at the v1 scale (tens to hundreds of threads per tenant).
OpenTelemetry
The platform speaks OpenTelemetry out of the box, off by default. Set one env var to turn it on:
OTEL_EXPORTER_OTLP_ENDPOINT=http://your-collector:4318When unset the API still emits to a ConsoleMetricExporter fall-through (handy for local dev) but doesn't ship anything to your observability backend.
Help-specific instruments live under spotlight.help.* (all tagged with tenant_id + project_id so dashboards slice cleanly):
| Instrument | Kind | What |
|---|---|---|
spotlight.help.requests_created | Counter | New help requests (tags has_picks) |
spotlight.help.replies_sent | Counter | Replies (tagged author_kind) |
spotlight.help.threads_resolved | Counter | Resolutions (tagged priority) |
spotlight.help.notifications_sent | Counter | Resend outbound (tagged kind/outcome) |
spotlight.help.first_response_seconds | Histogram | MTTFR distribution |
spotlight.help.resolution_seconds | Histogram | MTTR distribution |
spotlight.help.ratings_received | Counter | CSAT rating volume |
spotlight.help.rating_score | Histogram | CSAT score distribution (1-5) |
Every key endpoint sets span attributes (spotlight.help.request_id, .project_id, .priority, .author_kind, …) so traces are sliceable in Honeycomb / Tempo / Lightstep without you shipping a custom view.
Screenshots
Both sides (user widget + operator inbox) can attach up to 4 screenshots per message, capped at 5 MB each. Two ways in:
- Image button opens the native file picker, restricted to
image/png,image/jpeg,image/gif,image/webp. The widget uploads as soon as you pick — by the time the chip shows below the composer, the bytes are already in storage. - Screenshot button uses the browser's
getDisplayMedia()API. The user picks what to share (tab / window / screen), the widget grabs the first frame, and uploads it asimage/png. No external dep. Works in Chrome / Firefox / Edge today; Safari 17+ supports it too.
Uploads land in S3 under s3://<bucket>/<tenant_id>/<request_id>/<attachment_id>.<ext> and the metadata is stored inline on the request / reply row. Image bytes are stream-served via GET /v1/sdk/help/attachments/{id}?t=&s=, an HMAC-signed short-TTL URL (30 min) so the <img src> works without a bearer header. Rotate HELP_ATTACHMENT_SIGNING_SECRET to invalidate every outstanding link.
Why image-only
Deliberately narrower than "attach any file" — PDFs / zips / executables widen the blast radius for a feature that's almost always "let me show you what I'm seeing". The MIME allowlist is enforced server-side, and we magic-byte-sniff the first bytes to reject evil.exe renamed to evil.png.
Virus-scan integration
Every attachment carries a scan_status (clean, pending, infected, error). The signed URL returns empty ("") until the status is clean, so the widget renders a "Scanning…" chip in the meantime and the operator inbox shows the same.
The local stack starts every upload as clean — there's no scanner. To wire a real one in production:
- Default the entity's
scan_statusto"pending"when running in a real-AWS environment (env-gated). - Configure an S3
ObjectCreatedevent on the attachments bucket that triggers a Lambda (ClamAV, GuardDuty Malware Protection, VirusTotal, your choice). - The Lambda updates the DDB row's
attachments[].scan_statuswhen scanning completes. - The next read of the thread surfaces the cleared
urland the widget swaps the "Scanning…" chip for the thumbnail.
The status field is on the entity, the renderers gate on it, and the signed-URL builder returns "" when not clean — so the quarantine path works the moment a worker writes to that field.
Endpoints (for advanced integrations)
User-side (called by the SDK widget):
POST /v1/sdk/help/requests— submitGET /v1/sdk/help/requests/mine— list user's threadsGET /v1/sdk/help/requests/{id}— read a threadPOST /v1/sdk/help/requests/{id}/replies— user replyPOST /v1/sdk/help/requests/{id}/typing— typing-indicator pingPOST /v1/sdk/help/requests/{id}/read— clear unreadPOST /v1/sdk/help/requests/{id}/rating— CSAT rating (1-5 + optional comment)POST /v1/sdk/help/requests/{id}/resolve— end-user closes their own threadPOST /v1/sdk/help/requests/{id}/attachments— image upload (multipart)GET /v1/sdk/help/attachments/{id}?t=&s=— signed-URL stream (image bytes)GET /v1/sdk/help/articles— public FAQ list
Operator-side (admin-only):
GET /v1/admin/help/requests?status=&project_id=— inboxGET /v1/admin/help/requests/{id}— readPOST /v1/admin/help/requests/{id}/replies— operator replyPOST /v1/admin/help/requests/{id}/typing— typing pingPOST /v1/admin/help/requests/{id}/assign— assign / unassignPOST /v1/admin/help/requests/{id}/status— open / awaiting / resolvedPOST /v1/admin/help/requests/{id}/triage— priority + tagsPOST /v1/admin/help/requests/{id}/replies/{rid}/promote— to FAQPOST /v1/admin/help/requests/{id}/read— clear operator unreadGET /v1/admin/help/stats?days=&project_id=— MTTR / MTTFR / CSAT / leaderboardPUT /v1/admin/config/help-articles— replace the FAQ library
All endpoints accept a tenant API key (X-API-Key header) and follow the same auth model as the rest of the admin surface.
Roadmap
- Project-scoped data sources — connect MCP servers / HTTP endpoints per project. When a thread opens, the dashboard pulls
merchant.balance,account.tier, etc. from the host's API using the user's JWT, rendering the result alongside the Context map. - AI assist — proposed reply text in the composer, drafted from the captured context + the FAQ library.
- NPS + survey modules — same launcher / inbox / notification spine, different schemas.
- SLA email reminders — automated nudges to users who haven't responded in N days; cancel by closing the ticket.
These are documented in RFC-026 §11 and live behind feature flags.