Skip to content

RFC-026 — Contextual Help & Feedback Module

Status: Implementing (PR 1 in flight) Companion: ui_instrumentation_implementation_pack.md §11 (Contextual help integration) Author: 2026-05-16


1. Problem

End users on a tenant's product need a way to ask the tenant's operators (the people who own that product's Spotlight tenant) for help — and have those conversations resolved without leaving the page. Three constraints shape the design:

  1. The user is mid-task. Anything they have to do beyond "ask" is wasted UX. Context (what page, what entity, what state) should be captured automatically.
  2. The operator is somewhere else. Notify by email and surface a dashboard inbox so they don't miss it.
  3. Replies must close the loop. The user gets an in-page badge when the operator replies, and an email if the widget is closed.

Future iterations layer NPS surveys, MCP-driven data lookups, and an AI copilot on top — but the v1 ships the human-to-human feedback path end-to-end because that's what unlocks the rest.

2. Non-goals (this RFC)

  • AI-generated replies. Hooks for it exist in the schema; the v1 just routes to operators.
  • Multi-channel routing (Slack, MS Teams). Email + dashboard inbox only.
  • File attachments. Text + DOM context is enough for v1.

3. User-facing surface

Launcher

A small, always-visible affordance the host page can render in two modes:

ModeTrigger
FloatingSDK config help: { mount: 'floating', position: 'bottom-right' } (default)
EmbeddedPlace <div data-spotlight-help-mount></div> on the page; widget mounts inline

Visual: a circular pill (44 px) with a chat-bubble glyph in the tenant's accent colour. Unread reply count overlays as a corner badge. Hover label: "Need help?".

Composer

When the user clicks the launcher, the panel slides in (right side on floating mode, contextual on embedded). Sections:

  1. Pick what you're asking about (optional). Cursor flips to crosshair; clicking any DOM element captures its instrumentation context per §7 of the instrumentation pack. Multi-select — each pick lands as a removable chip. Falls back gracefully on un-instrumented pages (chip shows "Element on /dashboard").
  2. Tell us what's wrong. A textarea with quick-pick prompts ("This doesn't work", "How do I…", "Where's …", "Suggestion").
  3. Submit. Captures the user's identity from the SDK's identity layer (JWT email/name → fall back to userId).

Inbox

A second tab on the panel lists the user's open + recent requests. Each one expands to a thread; the user can reply if the operator's asked a follow-up.

Unread badge

The launcher carries a small accent dot when:

  • Any of the user's requests has unread operator replies, OR
  • The operator has marked one as "resolved" since the user last opened the inbox.

Cleared on inbox view.

4. Operator-facing surface

A new dashboard route /dashboard/help:

  • Three columns by status: Open / Awaiting customer / Resolved.
  • Each request card shows: user (email or id), route, captured context chips, free-text body, time-since-submit.
  • Click expands to thread; operator can reply, mark resolved, or re-open.
  • Filters: by project, by entity, by user.
  • New requests + new user replies surface as a sidebar badge.

Email goes out to every tenant admin on new request and every user reply.

5. Data model

Help.Requests table

PK: tenant_id        (S)
SK: request_id       (S)   # `hr_<uuid12>`

GSIs:
  gsi-user           (tenant_id_user_id, created_at)
  gsi-status         (tenant_id_status,  created_at)
FieldTypeNotes
tenant_idS
request_idShr_<uuid12>
project_idSfrom scope (per-project routing later)
user_idSfrom SDK identity, opaque
user_emailSfrom JWT claims if present, else ""
user_nameSfrom JWT claims if present
routeSSPA route at submit-time
page_urlSfull URL, query stripped
page_titleS
bodySuser's free text, max 4 KB
context_picksL of Meach:
statusSopen / awaiting_user / resolved
created_atSISO
updated_atSbumped on every reply / status change
last_actorSuser / operator — drives unread logic
user_unreadBOOLtrue while operator has unread replies
operator_unreadBOOLtrue while user has unread replies

Help.Replies table

PK: request_id       (S)
SK: reply_id         (S)   # `hrep_<uuid12>` — ksuid-style; sorts chronologically
FieldTypeNotes
request_idS
reply_idSsortable
author_kindSuser / operator
author_idSuser_id or platform_user_id
author_emailSdenormalised for the operator inbox
author_nameS
bodySmax 4 KB
created_atSISO

6. Endpoints

SDK (user-facing)

MethodPathPurpose
POST/v1/sdk/help/requestsSubmit a new request
GET/v1/sdk/help/requests/mineList the user's own requests + reply counts
GET/v1/sdk/help/requests/{id}Read a single request with replies
POST/v1/sdk/help/requests/{id}/repliesAppend a user reply
POST/v1/sdk/help/requests/{id}/readMark replies as read (clears badge)

Identity comes from the SDK auth context (JWT or X-Spotlight-Demo-User in dev). user_id keys all queries; one user can only see their own requests.

Admin (operator-facing)

MethodPathPurpose
GET/v1/admin/help/requestsList inbox (filters: status, project)
GET/v1/admin/help/requests/{id}Read a request with full thread
POST/v1/admin/help/requests/{id}/repliesOperator reply
POST/v1/admin/help/requests/{id}/statusSet status (open/awaiting_user/resolved)
POST/v1/admin/help/requests/{id}/readClear operator_unread

7. Notifications

Email (Resend)

  • New request → all tenant admins (or those subscribed to help_inbox notifications — wired off the existing Admin.Users table). Subject: [Help] <route>: <body excerpt>.
  • User reply → all tenant admins (same audience).
  • Operator reply → the user (if SDK widget is closed, i.e. last_actor == 'operator' AND the user hasn't read in 5 min). MVP always emails; v2 adds the open/closed heuristic.

Sender uses the tenant-level email_from override added in e2b1356, falls back to Spotlight <no-reply@spotlight.<apex>>.

In-app

  • User badge — small accent dot on launcher when user_unread == true. Cleared on inbox view (POST /read).
  • Operator badge — sidebar dot in the dashboard when any request has operator_unread == true for the current tenant.

8. Context capture

The element picker reads data-ui-* attributes per the instrumentation pack §7. For each selected element we capture:

ts
{
  name?: string,              // data-ui-name
  entity?: string,            // walked up
  entity_id?: string,         // walked up
  state?: string,             // data-ui-state
  ancestors: string[],        // data-ui-name of each ancestor with one
  snippet: string,            // up to 80 chars of textContent for fallback display
  rect: { x: number, y: number, w: number, h: number }, // for visual reference in the operator UI
}

For elements without data-ui-* we still capture snippet and rect so the operator sees what was clicked.

9. Privacy + safety

  • Body is plain text. Operators see exactly what the user typed — no auto-redaction.
  • The captured context is data-ui-* only; the instrumentation pack §13.4 forbids sensitive data in those attributes, so by design no PII lands in context picks.
  • Per-tenant isolation: the SDK endpoints require an API key scoped to the tenant; the user_id is taken from the auth context, not the request body.
  • Rate limit: 10 new requests per user per hour, 50 per tenant per minute. Beyond, return 429.

10. Configuration

SDK init option:

js
Spotlight.init({
  apiKey: '...',
  help: {
    mount: 'floating' | 'embedded',    // default 'floating'
    position: 'bottom-right' | 'bottom-left',  // floating only
    label: 'Need help?',               // tooltip on launcher
    quickPrompts: ['How do I…', "This doesn't work", 'Suggestion'],
    disabled: false,                    // off-switch for routes that shouldn't show it
  },
})

mount: 'embedded' looks for the first element with data-spotlight-help-mount and renders the launcher inside it. If none found, falls back to floating.

11. Rollout

  • PR 1 (this RFC): backend schema + endpoints + SDK widget (launcher + composer + element picker + user inbox + polling badge) + dashboard inbox + email notifications.
  • PR 2: per-route disable rules (tenant config), operator email-subscription preferences, rate limits.
  • PR 3: NPS + survey modules sitting alongside Help (same inbox/notification spine).
  • PR 4: data-source connectors (MCP) feeding optional AI suggestions to the operator on new requests; operator approves or rewrites before send.

12. Open questions

  • Anonymous users. If the host page doesn't identify the user (no JWT, no userId), do we accept the request? MVP says yes — request still routes to operators; user can't read replies (no inbox without identity). Acceptance criterion: at least one contact field (the body itself, or an opt-in email field in composer).
  • Email-only operators? Some teams might prefer email-reply (the operator emails back, we ingest via Resend webhook). v2.
  • Mention support. Linking to the help request from another surface (e.g. tour completion) needs a stable URL. Use /dashboard/help/<request_id>.

End of RFC-026.

Last updated:

Spotlight