Skip to content

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

html
<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):

js
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:

html
<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)

js
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:

js
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:

FieldMeaning
TitleWhat the user clicks in the composer's "Quick answers" section.
BodyPlain text + basic markdown (**bold**, paragraphs, lists).
RoutesOptional URL patterns — e.g. /customers, /orders/*. Empty = always.
TagsFree-form metadata for ordering and future search.

The composer matches articles by location.pathname; endsWith

  • includes matching 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):

TriggerRecipients
New requestAll tenant admins (Admin.Users at admin/owner role).
User replyAll tenant admins.
Operator replyThe 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:

  1. Per-admin allowlist — every Admin.Users row has a project_ids set. 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.
  2. 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 panelcsat_percent (% of ratings that were 4 or 5), avg_rating, distribution across 1-5.
  • The leaderboard — per-operator avg_rating + rated_count alongside 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:

FieldWhat
total/open/awaiting_user/resolvedVolume counts over the window.
by_priority / by_tagBucket sums driving the triage roll-ups.
avg_first_response_secondsMTTFR — mean time to the first operator reply.
avg_resolution_secondsMTTR — mean time from create → resolved.
p90_resolution_secondsSlowest 10% — outlier-aware SLA metric.
csat_percent / avg_rating / rating_distributionCSAT 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:

bash
OTEL_EXPORTER_OTLP_ENDPOINT=http://your-collector:4318

When 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):

InstrumentKindWhat
spotlight.help.requests_createdCounterNew help requests (tags has_picks)
spotlight.help.replies_sentCounterReplies (tagged author_kind)
spotlight.help.threads_resolvedCounterResolutions (tagged priority)
spotlight.help.notifications_sentCounterResend outbound (tagged kind/outcome)
spotlight.help.first_response_secondsHistogramMTTFR distribution
spotlight.help.resolution_secondsHistogramMTTR distribution
spotlight.help.ratings_receivedCounterCSAT rating volume
spotlight.help.rating_scoreHistogramCSAT 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 as image/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:

  1. Default the entity's scan_status to "pending" when running in a real-AWS environment (env-gated).
  2. Configure an S3 ObjectCreated event on the attachments bucket that triggers a Lambda (ClamAV, GuardDuty Malware Protection, VirusTotal, your choice).
  3. The Lambda updates the DDB row's attachments[].scan_status when scanning completes.
  4. The next read of the thread surfaces the cleared url and 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 — submit
  • GET /v1/sdk/help/requests/mine — list user's threads
  • GET /v1/sdk/help/requests/{id} — read a thread
  • POST /v1/sdk/help/requests/{id}/replies — user reply
  • POST /v1/sdk/help/requests/{id}/typing — typing-indicator ping
  • POST /v1/sdk/help/requests/{id}/read — clear unread
  • POST /v1/sdk/help/requests/{id}/rating — CSAT rating (1-5 + optional comment)
  • POST /v1/sdk/help/requests/{id}/resolve — end-user closes their own thread
  • POST /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= — inbox
  • GET /v1/admin/help/requests/{id} — read
  • POST /v1/admin/help/requests/{id}/replies — operator reply
  • POST /v1/admin/help/requests/{id}/typing — typing ping
  • POST /v1/admin/help/requests/{id}/assign — assign / unassign
  • POST /v1/admin/help/requests/{id}/status — open / awaiting / resolved
  • POST /v1/admin/help/requests/{id}/triage — priority + tags
  • POST /v1/admin/help/requests/{id}/replies/{rid}/promote — to FAQ
  • POST /v1/admin/help/requests/{id}/read — clear operator unread
  • GET /v1/admin/help/stats?days=&project_id= — MTTR / MTTFR / CSAT / leaderboard
  • PUT /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.

Spotlight