introMessage).
Plan to spend ~45 minutes the first time through. Every file is included verbatim.
What you’ll add to the Step 1 build:
bridge/proactive.py— a dedicated module withProactiveState,GuidanceEvent,ProactiveOffer, and the_detect_stuck_onboardingpredicate. Keeping trigger logic separate from the context-pull code (incopilot_server.py) follows the same pattern as the Rasa tutorial and makes each layer independently testable.- Demo-action tracking in the bridge —
POST /demo/actionsaccepts explicit onboarding events from the frontend (data_source_open,data_source_complete), keyed byuser_id. Unlike the PostHog autocapture pipeline from Step 1, these events are sent directly from UI components that know exactly when the panel opens and closes — the bridge doesn’t need to poll anything for this signal, it arrives already labeled. - SSE guidance channel —
GET /guidance/stream/{user_id}deliveringguidance_offerandguidance_startevents in real time. - Updated context endpoint —
GET /context/{user_id}(replaces the Step 1 version) now merges a fresh REST pull of Autoplay activity with the stuck-connection narrative so theintroMessageis maximally specific. - Frontend proactive popup component — a dismissible card with two CTAs, rendered outside the chat overlay so it appears whether or not the chat is open.
- Two resolution paths:
- Show me → a step-by-step pulsing overlay that walks through the four connection fields.
- Open chat →
InkeepEmbeddedChatremounts with a newintroMessagecontaining the live Autoplay context.
📋 Before you start
This page picks up exactly where Step 1 left off. From Step 1 you should already have:bridge/copilot_server.pyrunning on:8787withpull_live_activity()(a plainhttpxcall to the Autoplay connector) andGET /context/{user_id}frontend/Next.js app withInkeepWidgetandInkeepEmbeddedChatmounted, plus the PostHog provider- Inkeep agents framework running on
:3002with thenexus-cloudproject,onboarding-supportagent, andonboarding-support-workersub-agent
No
session_id anywhere in this step either. Step 1 already collapsed the read side onto the stable user_id. Everything below — the demo-action tracking, the SSE guidance channel, the merged context endpoint — is keyed the same way, so there’s a single identity to reason about end to end instead of a user_id ↔ session_id index to maintain.🧠 Step 1 — Create bridge/proactive.py
Create a new file bridge/proactive.py. Keeping proactive state and trigger logic in its own module means copilot_server.py only needs to import and wire it — the same separation used in the Rasa tutorial’s proactive.py.
bridge/proactive.py — proactive state and trigger detection (expand to copy)
bridge/proactive.py — proactive state and trigger detection (expand to copy)
The
completed guard is the most important part. Without it the trigger fires even for users who successfully connected their data source and then reopened the step out of curiosity. Always check whether the goal action happened before deciding the user is stuck.🧰 Step 2 — Extend bridge/copilot_server.py with demo-action tracking
Add the following imports at the top of bridge/copilot_server.py (after the existing ones):
Why this doesn’t need a background poller. The trigger predicate evaluates explicit, purpose-built events (
data_source_open, data_source_complete) that the frontend pushes the instant they happen — handle_demo_actions runs the check synchronously on every incoming batch. There’s no timer sweeping active users and no PostHog autocapture noise to filter: the signal is already clean by construction, which is also why it stays a separate pipeline from the Step 1 pull_live_activity() call that grounds introMessage in real PostHog activity. Both are keyed by the same user_id, so there’s nothing to reconcile between them.📡 Step 3 — Add the SSE, context, and reply routes
Add these routes tobridge/copilot_server.py.
| Route | Direction | Purpose |
|---|---|---|
GET /guidance/stream/{user_id} | Bridge → Frontend | SSE channel delivering guidance_offer and guidance_start events |
GET /context/{user_id} | Frontend → Bridge | Merged live-activity + demo context for InkeepEmbeddedChat injection (replaces the Step 1 version) |
POST /inkeep/reply | Frontend → Bridge | User accepted the offer; emits guidance_start |
POST /demo/actions | Frontend → Bridge | Explicit onboarding events; also returns pending_offer |
bridge/copilot_server.py — new SSE, context, and reply routes (expand to copy)
bridge/copilot_server.py — new SSE, context, and reply routes (expand to copy)
| Event name | When | Frontend action |
|---|---|---|
guidance_offer | Bridge crossed the 3-open threshold | Show proactive popup |
guidance_start | User accepted offer via POST /inkeep/reply | Start the four-step guided tour |
🔥 Step 4 — Smoke test the trigger chain
Simulate three data source opens for the same user:"pending_offer": true and a full "offer": {...} object. Now verify the context endpoint:
"context" string describing the three data_source_open events. Now accept the offer:
guidance_start chain works. Now wire the frontend.
🔔 Step 5 — Add SSE subscription to the frontend
Add auseEffect to your page component that subscribes to the guidance SSE stream. The EventSource API fires named events — guidance_offer and guidance_start are matched by their event name on the stream.
sendDataSourceOpen helper to lib/demo-api.ts so the frontend can report explicit onboarding events:
frontend/lib/demo-api.ts — bridge API helpers (expand to copy)
frontend/lib/demo-api.ts — bridge API helpers (expand to copy)
sendDataSourceOpen(userId) in your onboarding component whenever the user opens the Connect Data Source panel:
🪟 Step 6 — The proactive popup component
Add the popup render to your page component. It lives outside the chat overlay so it can appear whether or not the chat is open:frontend/app/styles.css:
frontend/app/styles.css — proactive popup styles (expand to copy)
frontend/app/styles.css — proactive popup styles (expand to copy)
🗺 Step 7 — The guideStep state machine
The guided tour is driven by a guideStep state variable. Each state causes a different element on the page to pulse and show a tooltip:
| State | What pulses | Tooltip |
|---|---|---|
step1 | API Endpoint field | ”Paste your API endpoint URL here — check your provider’s integration settings page.” |
step2 | API Key field | ”Enter the API key from your provider dashboard — it usually starts with sk- or api_.” |
step3 | Test Connection button | ”Click to verify the connection — most failures at this stage are IP whitelist related. Make sure your provider has whitelisted your server’s outbound IP.” |
step4 | Mark Complete button | ”Connection verified — click here to mark this step as done and move on to Invite Team.” |
done | Nothing | Guide complete. |
crmBtn--pulse class (defined in Step 6’s CSS) to the element matching the current guideStep, and render a <GuideTooltip> next to it. Advance the step on the user’s click:
_detect_stuck_onboarding checks — once a data_source_complete action is in the window, future opens won’t trigger the popup.
💬 Step 8 — The “Open chat” path
When the user clicks Open chat on the proactive popup:- Call
acceptInkeepOffer(userId, offer.id)— this hitsPOST /inkeep/reply, which publishes theguidance_startSSE event and removes the offer frompending_offer_by_user. - Fetch the merged context from
GET /context/{user_id}. - Set a
contextKeystate that changes, forcingInkeepEmbeddedChatto remount with the newintroMessage.
contextKey and contextMessage into InkeepWidget:
InkeepWidget, the introMessage derives from contextMessage:
✅ Step 9 — End-to-end walkthrough
Start all three services:- Open
http://localhost:3000. - The onboarding dashboard renders with five steps. “Connect Data Source” is the first.
- Click to expand the Connect Data Source step —
sendDataSourceOpenfires, the firstdata_source_openaction is sent to the bridge. - Close the step panel without completing it.
- Expand it again — second action sent.
- Close it again without completing.
- Expand it a third time — third action sent. The bridge crosses the threshold and publishes
guidance_offerover SSE. - The proactive popup appears bottom-right: “Having trouble connecting your data source? The most common issue is IP whitelisting — want me to walk you through it?”
- Click Show me how to fix the connection. The popup dismisses. The API Endpoint field starts pulsing with a tooltip: “Paste your API endpoint URL here…”
- Focus the API Endpoint field (or click through the tooltip) — the step advances to
step2. The API Key field pulses. - Focus the API Key field — step advances to
step3. The Test Connection button pulses. - Click Test Connection — step advances to
step4. The Mark Complete button pulses. - Click Mark Complete —
guideStepbecomesdone. Adata_source_completeaction is sent to the bridge. The step is marked done.
- Click Open chat. The frontend fetches
GET /context/{user_id}and gets the merged activity + stuck-connection summary. - The chat overlay opens.
InkeepEmbeddedChatinitializes with the context asintroMessage— something like: “The user is working through the Nexus Cloud onboarding. Here is what they have been doing recently: [data_source_open × 3] They appear to be struggling with the Connect Data Source step. Start by acknowledging this and offer specific guidance.” - The AI’s first reply references the connection struggle directly. Ask a follow-up question — the AI responds in context.
🛠 Troubleshooting
| Symptom | Likely cause |
|---|---|
| Popup never appears after 3 opens | sendDataSourceOpen not called, or SSE stream not connected. Check bridge logs for "published guidance_offer to 0 subscriber(s)" — if subscribers=0, the frontend’s EventSource isn’t open yet. |
| SSE stream closes immediately | Missing CORS on bridge; check that CORSMiddleware in copilot_server.py includes http://localhost:3000 in allow_origins. |
guidance_offer event fires but guided tour doesn’t start | The guidance_start SSE event isn’t wired — check that POST /inkeep/reply is called on the “Show me” click and that handleShowMe sets guideStep("step1") on success. |
| Context injection empty even with activity | GET /context/{user_id} returns "" — confirm (1) sendDataSourceOpen is sending the same user_id as the SSE subscription, and (2) the bridge is running with the proactive.py import in scope (no ModuleNotFoundError at startup). |
InkeepEmbeddedChat shows old introMessage after proactive open | key prop not changing — set key={contextKey} on InkeepWidget (or directly on InkeepEmbeddedChat) and ensure setContextKey((k) => k + 1) is called in handleOpenChat. |
ModuleNotFoundError: No module named 'proactive' at bridge startup | proactive.py must be in the same directory as copilot_server.py. Run uvicorn from ~/nexus-cloud/bridge/. |
PATCH defaultSubAgentId returns 404 | Sub-agent ID typo; verify with GET /manage/tenants/default/projects/nexus-cloud/agents/onboarding-support and confirm the sub-agent exists. |
| Anonymous session JWT fails (401) | allowAnonymous not set; re-run the UPDATE apps SET config = ... SQL from Step 1, or verify with SELECT config FROM apps WHERE id = 'app_playground'. |
introMessage is always the generic greeting | fetchContext returned an empty string. Check (1) proactive_state.recent_actions[user_id] is populated (bridge should log "stored N actions"), and (2) the user_id in sendDataSourceOpen matches the one passed to fetchContext. |
| Live-activity half of the context is empty even after clicking around | pull_live_activity() got a non-200 or timed out — check bridge logs for "live-activity 401" (wrong MCP_KEY) or "timed out" (unreachable CONNECTOR_URL), and confirm the user_id matches the one posthog.identify() was called with. This was also covered in Step 1’s troubleshooting table. |
🔄 Day-2 operations
--reload to pick up the change. The change only affects users who haven’t been evaluated yet; any user who already received an offer won’t be re-triggered.
To add a second trigger — for example, repeated failed syncs when users reach step 5 — add a new _detect_stuck_sync function to proactive.py and call it from handle_demo_actions alongside _detect_stuck_onboarding. The SSE channel, popup, and guided-tour machinery are already in place.
What you’ve built
You now have an onboarding dashboard that goes proactive at the exact moment a user gets stuck — driven by a simple action-count predicate, gated by a completion check, and surfaced through a popup that gives the user a real choice between show me and tell me. A few things worth noting:- The popup is owned by your app, not the chat vendor. It appears whether or not the chat is open — the only architecture that lets proactive nudges land in context.
- Two separate pipelines, one bridge, one identity.
pull_live_activity()(from Step 1) grounds reactive chat replies in real PostHog activity, pulled fresh on demand. TheProactiveStatepipeline tracks explicit onboarding events, pushed synchronously, for trigger detection. They coexist incopilot_server.pywithout interfering — and both are keyed by the sameuser_id, so there’s no session index to keep them in sync. - The trigger is just a predicate.
_detect_stuck_onboardingis one rule covering one step. Add a second trigger by adding a new detection function toproactive.pyand calling it fromhandle_demo_actions— no other changes required. - Context injection makes the first AI message personal. The updated
GET /context/{user_id}endpoint returns a merge of freshly-pulled live activity and the stuck-connection narrative; that string becomes theintroMessageonInkeepEmbeddedChat. The user doesn’t have to explain their problem — the AI already knows. - Self-hosted AI. The Inkeep agents framework runs entirely on your infrastructure. LLM keys, conversation history, and system prompts never leave it.