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:
- 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.
- The operator is somewhere else. Notify by email and surface a dashboard inbox so they don't miss it.
- 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:
| Mode | Trigger |
|---|---|
| Floating | SDK config help: { mount: 'floating', position: 'bottom-right' } (default) |
| Embedded | Place <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:
- 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").
- Tell us what's wrong. A textarea with quick-pick prompts ("This doesn't work", "How do I…", "Where's …", "Suggestion").
- 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)| Field | Type | Notes |
|---|---|---|
| tenant_id | S | |
| request_id | S | hr_<uuid12> |
| project_id | S | from scope (per-project routing later) |
| user_id | S | from SDK identity, opaque |
| user_email | S | from JWT claims if present, else "" |
| user_name | S | from JWT claims if present |
| route | S | SPA route at submit-time |
| page_url | S | full URL, query stripped |
| page_title | S | |
| body | S | user's free text, max 4 KB |
| context_picks | L of M | each: |
| status | S | open / awaiting_user / resolved |
| created_at | S | ISO |
| updated_at | S | bumped on every reply / status change |
| last_actor | S | user / operator — drives unread logic |
| user_unread | BOOL | true while operator has unread replies |
| operator_unread | BOOL | true while user has unread replies |
Help.Replies table
PK: request_id (S)
SK: reply_id (S) # `hrep_<uuid12>` — ksuid-style; sorts chronologically| Field | Type | Notes |
|---|---|---|
| request_id | S | |
| reply_id | S | sortable |
| author_kind | S | user / operator |
| author_id | S | user_id or platform_user_id |
| author_email | S | denormalised for the operator inbox |
| author_name | S | |
| body | S | max 4 KB |
| created_at | S | ISO |
6. Endpoints
SDK (user-facing)
| Method | Path | Purpose |
|---|---|---|
| POST | /v1/sdk/help/requests | Submit a new request |
| GET | /v1/sdk/help/requests/mine | List 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}/replies | Append a user reply |
| POST | /v1/sdk/help/requests/{id}/read | Mark 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)
| Method | Path | Purpose |
|---|---|---|
| GET | /v1/admin/help/requests | List inbox (filters: status, project) |
| GET | /v1/admin/help/requests/{id} | Read a request with full thread |
| POST | /v1/admin/help/requests/{id}/replies | Operator reply |
| POST | /v1/admin/help/requests/{id}/status | Set status (open/awaiting_user/resolved) |
| POST | /v1/admin/help/requests/{id}/read | Clear operator_unread |
7. Notifications
Email (Resend)
- New request → all tenant admins (or those subscribed to
help_inboxnotifications — 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 == truefor the current tenant.
8. Context capture
The element picker reads data-ui-* attributes per the instrumentation pack §7. For each selected element we capture:
{
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:
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.