Skip to main content
In Step 1 you built an onboarding dashboard with an embedded Inkeep AI chat that answers product questions. This page makes it proactive — the bridge notices when a user keeps opening the “Connect Data Source” step without completing it, and surfaces a targeted offer before they give up and open a support ticket. The concrete example we’ll build catches the stuck-connection moment: a user opening the first onboarding step three or more times within two minutes without ever reaching a successful connection. The bridge interrupts politely with a popup that offers two paths — Show me how to fix the connection (a four-step in-app guide highlighting each configuration field in sequence) or Open chat (Inkeep chat opens with the user’s full Autoplay context already loaded into the introMessage). Plan to spend ~45 minutes the first time through. Every file is included verbatim. What you’ll add to the Step 1 build:
  1. bridge/proactive.py — a dedicated module with ProactiveState, GuidanceEvent, ProactiveOffer, and the _detect_stuck_onboarding predicate. Keeping trigger logic separate from the context-pull code (in copilot_server.py) follows the same pattern as the Rasa tutorial and makes each layer independently testable.
  2. Demo-action tracking in the bridgePOST /demo/actions accepts explicit onboarding events from the frontend (data_source_open, data_source_complete), keyed by user_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.
  3. SSE guidance channelGET /guidance/stream/{user_id} delivering guidance_offer and guidance_start events in real time.
  4. Updated context endpointGET /context/{user_id} (replaces the Step 1 version) now merges a fresh REST pull of Autoplay activity with the stuck-connection narrative so the introMessage is maximally specific.
  5. 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.
  6. Two resolution paths:
    • Show me → a step-by-step pulsing overlay that walks through the four connection fields.
    • Open chatInkeepEmbeddedChat remounts with a new introMessage containing the live Autoplay context.
The runtime loop:

📋 Before you start

This page picks up exactly where Step 1 left off. From Step 1 you should already have:
  • bridge/copilot_server.py running on :8787 with pull_live_activity() (a plain httpx call to the Autoplay connector) and GET /context/{user_id}
  • frontend/ Next.js app with InkeepWidget and InkeepEmbeddedChat mounted, plus the PostHog provider
  • Inkeep agents framework running on :3002 with the nexus-cloud project, onboarding-support agent, and onboarding-support-worker sub-agent
The code blocks below extend those files — they don’t replace them.
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_idsession_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.
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):
Add the CORS middleware and create the proactive state singleton after the existing FastAPI app setup:
Add new models after the existing imports section:
Add the demo-action handler after the existing helpers:
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 to bridge/copilot_server.py.
RouteDirectionPurpose
GET /guidance/stream/{user_id}Bridge → FrontendSSE channel delivering guidance_offer and guidance_start events
GET /context/{user_id}Frontend → BridgeMerged live-activity + demo context for InkeepEmbeddedChat injection (replaces the Step 1 version)
POST /inkeep/replyFrontend → BridgeUser accepted the offer; emits guidance_start
POST /demo/actionsFrontend → BridgeExplicit onboarding events; also returns pending_offer
Two SSE event names flow from bridge to frontend:
Event nameWhenFrontend action
guidance_offerBridge crossed the 3-open thresholdShow proactive popup
guidance_startUser accepted offer via POST /inkeep/replyStart the four-step guided tour
Restart the bridge:

🔥 Step 4 — Smoke test the trigger chain

Simulate three data source opens for the same user:
The third response should include "pending_offer": true and a full "offer": {...} object. Now verify the context endpoint:
You should see a "context" string describing the three data_source_open events. Now accept the offer:
The full trigger → offer → accept → guidance_start chain works. Now wire the frontend.

🔔 Step 5 — Add SSE subscription to the frontend

Add a useEffect 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.
The SSE listener must use addEventListener("guidance_offer", ...) with the exact event name, not es.onmessage. The bridge emits named events (event: guidance_offer\ndata: ...\n\n) — onmessage only fires for unnamed events (data: ...\n\n). If the popup never appears despite the bridge logging a published offer, check this first.
Also add a sendDataSourceOpen helper to lib/demo-api.ts so the frontend can report explicit onboarding events:
Call 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:
Add the popup styles to frontend/app/styles.css:

🗺 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:
StateWhat pulsesTooltip
step1API Endpoint field”Paste your API endpoint URL here — check your provider’s integration settings page.”
step2API Key field”Enter the API key from your provider dashboard — it usually starts with sk- or api_.”
step3Test 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.”
step4Mark Complete button”Connection verified — click here to mark this step as done and move on to Invite Team.”
doneNothingGuide complete.
Apply the 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:
Add the data source complete action to the bridge payload when the user marks the step complete:
This is what _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:
  1. Call acceptInkeepOffer(userId, offer.id) — this hits POST /inkeep/reply, which publishes the guidance_start SSE event and removes the offer from pending_offer_by_user.
  2. Fetch the merged context from GET /context/{user_id}.
  3. Set a contextKey state that changes, forcing InkeepEmbeddedChat to remount with the new introMessage.
Pass contextKey and contextMessage into InkeepWidget:
Inside InkeepWidget, the introMessage derives from contextMessage:
The key prop must change for InkeepEmbeddedChat to pick up the new introMessage. A changed key tells React to unmount the old instance and mount a fresh one — without this, the chat re-opens with whatever introMessage it was initialized with. If you see the old generic greeting after clicking “Open chat,” check that contextKey is incrementing and that key={contextKey} is on the InkeepEmbeddedChat element (or its parent InkeepWidget).

✅ Step 9 — End-to-end walkthrough

Start all three services:
Then exercise the full flow:
  1. Open http://localhost:3000.
  2. The onboarding dashboard renders with five steps. “Connect Data Source” is the first.
  3. Click to expand the Connect Data Source step — sendDataSourceOpen fires, the first data_source_open action is sent to the bridge.
  4. Close the step panel without completing it.
  5. Expand it again — second action sent.
  6. Close it again without completing.
  7. Expand it a third time — third action sent. The bridge crosses the threshold and publishes guidance_offer over SSE.
  8. 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?”
Path A — Show me how to fix the connection:
  1. 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…”
  2. Focus the API Endpoint field (or click through the tooltip) — the step advances to step2. The API Key field pulses.
  3. Focus the API Key field — step advances to step3. The Test Connection button pulses.
  4. Click Test Connection — step advances to step4. The Mark Complete button pulses.
  5. Click Mark CompleteguideStep becomes done. A data_source_complete action is sent to the bridge. The step is marked done.
Path B — Open chat (reload first to reset state):
  1. Click Open chat. The frontend fetches GET /context/{user_id} and gets the merged activity + stuck-connection summary.
  2. The chat overlay opens. InkeepEmbeddedChat initializes with the context as introMessage — 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.”
  3. The AI’s first reply references the connection struggle directly. Ask a follow-up question — the AI responds in context.
In the bridge log you should see, in order:

🛠 Troubleshooting

SymptomLikely cause
Popup never appears after 3 openssendDataSourceOpen 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 immediatelyMissing 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 startThe 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 activityGET /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 openkey 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 startupproactive.py must be in the same directory as copilot_server.py. Run uvicorn from ~/nexus-cloud/bridge/.
PATCH defaultSubAgentId returns 404Sub-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 greetingfetchContext 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 aroundpull_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

To update the onboarding agent’s system prompt without restarting:
Prompts are loaded per-conversation from the manage DB — no service restart needed. To change the trigger threshold (number of opens before the popup fires):
Restart the bridge with --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. The ProactiveState pipeline tracks explicit onboarding events, pushed synchronously, for trigger detection. They coexist in copilot_server.py without interfering — and both are keyed by the same user_id, so there’s no session index to keep them in sync.
  • The trigger is just a predicate. _detect_stuck_onboarding is one rule covering one step. Add a second trigger by adding a new detection function to proactive.py and calling it from handle_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 the introMessage on InkeepEmbeddedChat. 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.
If anything in this tutorial wasn’t clear, or you hit a snag the troubleshooting matrix didn’t cover — please reply on the thread or open an issue in the Autoplay SDK repo. Feedback shapes the next version of these docs.