Use this file to discover all available pages before exploring further.
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:
bridge/proactive.py — a dedicated module with ProactiveState, GuidanceEvent, ProactiveOffer, and the _detect_stuck_onboarding predicate. Keeping trigger logic separate from the SDK pipeline (in copilot_server.py) follows the same pattern as the Rasa tutorial and makes each layer independently testable.
Demo-action tracking in the bridge — POST /demo/actions accepts explicit onboarding events from the frontend (data_source_open, data_source_complete). 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.
SSE guidance channel — GET /guidance/stream/{session_id} delivering guidance_offer and guidance_start events in real time.
Updated context endpoint — GET /context/{session_id} (replaces the Step 1 version) now merges the Autoplay SDK activity summary with the stuck-connection narrative so the introMessage is 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 → InkeepEmbeddedChat remounts with a new introMessage containing the live Autoplay context.
The runtime loop:
User opens "Connect Data Source" × 3 without completing │ ▼Bridge: _detect_stuck_onboarding fires │ ▼guidance_offer → SSE → proactive popup appears │ ┌────────────────────┴────────────────────┐ ▼ ▼ "Show me how to fix "Open chat" the connection" │ │ ▼ ▼ GET /context/{session_id} guideStep state machine → merged SDK + demo context step1 → step2 → step3 → step4 → introMessage on API Endpoint → API Key → InkeepEmbeddedChat Test Connection → Mark Complete
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 AsyncConnectorClient, AsyncContextStore, AsyncSessionSummarizer, AsyncAgentContextWriter, SessionState, and GET /context/{session_id}
_user_sessions, _session_product, _session_states dicts populated by on_actions
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.
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)
"""Proactive guidance state and trigger detection for the Inkeep onboarding bridge.ProactiveState is kept separate from the Autoplay SDK pipeline (AsyncContextStore)so the two state models don't interfere: AsyncContextStore owns the rolling eventwindow for chat grounding; ProactiveState owns the explicit demo-action countsand SSE fan-out for trigger delivery."""from __future__ import annotationsimport asyncioimport timefrom collections import defaultdict, dequefrom dataclasses import asdict, dataclassfrom typing import Anyfrom uuid import uuid4TRIGGER_WINDOW_SECONDS = 120DATA_SOURCE_OPEN_THRESHOLD = 3@dataclassclass GuidanceEvent: type: str # "guidance_offer" or "guidance_start" session_id: str message: str target: str tour_id: str reason: str created_at: float@dataclassclass ProactiveOffer: id: str session_id: str user_id: str | None email: str | None message: str target: str tour_id: str reason: str created_at: floatclass ProactiveState: """Lightweight in-memory store for the proactive guidance layer.""" def __init__(self) -> None: self.recent_actions: dict[str, deque[dict[str, Any]]] = defaultdict( lambda: deque(maxlen=30) ) self.pending_offer_by_session: dict[str, ProactiveOffer] = {} self.guidance_subscribers: dict[str, set[asyncio.Queue[GuidanceEvent]]] = defaultdict(set) def store_actions(self, session_id: str, actions: list[dict[str, Any]]) -> None: bucket = self.recent_actions[session_id] now = time.time() for action in actions: item = dict(action) item.setdefault("timestamp_start", now) bucket.append(item) async def publish_guidance(self, event: GuidanceEvent) -> None: for queue in list(self.guidance_subscribers[event.session_id]): await queue.put(event)def _is_data_source_open(action: dict[str, Any]) -> bool: return action.get("type", "") == "data_source_open"def _detect_stuck_onboarding( *, session_id: str, user_id: str | None, email: str | None, state: ProactiveState) -> ProactiveOffer | None: # Guard: fire at most once per session if session_id in state.pending_offer_by_session: return None cutoff = time.time() - TRIGGER_WINDOW_SECONDS recent = [ a for a in state.recent_actions[session_id] if float(a.get("timestamp_start") or 0) >= cutoff ] # If the user already completed the step in this window, don't nudge them completed = any( a.get("type") == "data_source_complete" for a in recent ) if completed: return None open_clicks = [a for a in recent if _is_data_source_open(a)] if len(open_clicks) < DATA_SOURCE_OPEN_THRESHOLD: return None return ProactiveOffer( id=f"offer_{uuid4().hex}", session_id=session_id, user_id=user_id, email=email, message=( "Having trouble connecting your data source? " "The most common issue is IP whitelisting — " "want me to walk you through it?" ), target="[data-guide='connect-data-source']", tour_id="data-source-connection", reason=f"{len(open_clicks)} data_source_open actions in {TRIGGER_WINDOW_SECONDS}s without completion", created_at=time.time(), )
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.
Two separate action pipelines:on_actions feeds AsyncContextStore with PostHog autocapture events for SDK-grounded chat context. handle_demo_actions feeds ProactiveState with explicit frontend events (data_source_open, data_source_complete) for trigger detection. They are intentionally separate — PostHog events are too noisy for precise completion detection, while explicit events give you a clean predicate.
SSE channel delivering guidance_offer and guidance_start events
GET /context/{session_id}
Frontend → Bridge
Merged SDK + 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)
@app.get("/guidance/stream/{session_id}")async def guidance_stream(session_id: str, request: Request) -> StreamingResponse: queue: asyncio.Queue[GuidanceEvent] = asyncio.Queue() proactive_state.guidance_subscribers[session_id].add(queue) async def events(): try: # Send a ready event immediately so the client knows it's connected yield "event: ready\ndata: {}\n\n" while not await request.is_disconnected(): try: event = await asyncio.wait_for(queue.get(), timeout=15) yield f"event: {event.type}\ndata: {json.dumps(asdict(event))}\n\n" except asyncio.TimeoutError: yield "event: heartbeat\ndata: {}\n\n" finally: proactive_state.guidance_subscribers[session_id].discard(queue) return StreamingResponse(events(), media_type="text/event-stream")@app.get("/context/{session_id}")async def get_context(session_id: str) -> dict[str, Any]: """Return assembled context for injection into InkeepEmbeddedChat. Merges two sources: 1. SDK context from AsyncContextStore (PostHog autocapture events via Step 1). 2. Demo-specific stuck-connection narrative (explicit data_source_open events). Replaces the Step 1 version of this endpoint with one that is also aware of the proactive guidance state. """ # Source 1 — Autoplay SDK activity context (assembled rolling summary) product_id = _session_product.get(session_id) sdk_context = context_store.get(session_id, product_id=product_id) # Source 2 — explicit demo actions tracked by ProactiveState demo_actions = list(proactive_state.recent_actions.get(session_id, [])) if not sdk_context and not demo_actions: return {"context": "", "has_activity": False, "session_id": session_id} lines = [] for a in demo_actions[-10:]: # last 10 explicit events atype = a.get("type", "") title = a.get("title", "") ts_raw = a.get("timestamp_start", 0) try: ts = float(ts_raw) except (TypeError, ValueError): ts = 0.0 lines.append(f"- [{atype}] {title} (t={ts:.0f})") parts = [] if sdk_context: parts.append(sdk_context) if lines: parts.append( "The user is working through the Nexus Cloud onboarding. " "Here is what they have been doing recently:\n" + "\n".join(lines) + "\n\nThey appear to be struggling with the Connect Data Source step. " "Start by acknowledging this and offer specific guidance." ) context = "\n\n".join(parts) return { "context": context, "has_activity": bool(context.strip()), "session_id": session_id, }@app.post("/inkeep/reply")async def inkeep_reply(reply: InkeepReply) -> dict[str, Any]: offer = proactive_state.pending_offer_by_session.pop(reply.session_id, None) if offer is None: raise HTTPException( status_code=404, detail="No pending offer for this session" ) if reply.offer_id and offer.id != reply.offer_id: raise HTTPException(status_code=409, detail="Offer ID does not match") accepted = GuidanceEvent( type="guidance_start", session_id=offer.session_id, message="Start the data source connection guide.", target=offer.target, tour_id=offer.tour_id, reason=offer.reason, created_at=time.time(), ) await proactive_state.publish_guidance(accepted) return {"accepted": True, "guidance": asdict(accepted)}@app.post("/demo/actions")async def demo_actions(payload: DemoActionsPayload) -> dict[str, Any]: await handle_demo_actions(payload) offer = proactive_state.pending_offer_by_session.get(payload.session_id) return { "stored": len(payload.actions), "pending_offer": offer is not None, "offer": asdict(offer) if offer else None, }
Two SSE event names flow from bridge to frontend:
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
Restart the bridge:
cd ~/nexus-cloud/bridgeuv run uvicorn copilot_server:app --host 0.0.0.0 --port 8787 --reload
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.
useEffect(() => { if (!sessionId) return; const es = new EventSource(guidanceStreamUrl(sessionId)); es.addEventListener("guidance_offer", (e) => { const event = JSON.parse((e as MessageEvent).data) as GuidanceEvent; setProactiveOffer(event); }); es.addEventListener("guidance_start", (e) => { const event = JSON.parse((e as MessageEvent).data) as GuidanceEvent; // Bridge confirmed the user accepted — start the guided tour setGuideStep("step1"); setProactiveOffer(null); }); return () => es.close();}, [sessionId]);
The SSE listener must use addEventListener("guidance_offer", ...) with the exact event name, notes.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:
frontend/lib/demo-api.ts — bridge API helpers (expand to copy)
Call sendDataSourceOpen(sessionId) in your onboarding component whenever the user opens the Connect Data Source panel:
// Call this when the Connect Data Source panel opensasync function handleDataSourcePanelOpen() { if (sessionId) { await sendDataSourceOpen(sessionId).catch(() => {}); }}
”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.
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:
function handleTestConnection() { // your existing connection logic... if (guideStep === "step3") setGuideStep("step4");}function handleMarkComplete() { // mark the step complete in your onboarding state... if (guideStep === "step4") setGuideStep("done");}
Add the data source complete action to the bridge payload when the user marks the step complete:
When the user clicks Open chat on the proactive popup:
Call acceptInkeepOffer(sessionId, offer.id) — this hits POST /inkeep/reply, which publishes the guidance_start SSE event and removes the offer from pending_offer_by_session.
Fetch the merged context from GET /context/{session_id}.
Set a contextKey state that changes, forcing InkeepEmbeddedChat to remount with the new introMessage.
const [contextKey, setContextKey] = useState(0);const [contextMessage, setContextMessage] = useState<string | undefined>();async function handleOpenChat() { setProactiveOffer(null); // 1. Tell the bridge the user accepted (publishes guidance_start) if (offer) { await acceptInkeepOffer(sessionId, offer.id).catch(() => {}); } // 2. Fetch merged Autoplay + demo context const ctx = await fetchContext(sessionId); if (ctx) { setContextMessage(ctx); // 3. Bump key to force InkeepEmbeddedChat remount with new introMessage setContextKey((k) => k + 1); } setChatOpen(true);}
Pass contextKey and contextMessage into InkeepWidget:
Inside InkeepWidget, the introMessage derives from contextMessage:
const introMessage = chatOpen && contextMessage ? contextMessage : chatOpen ? "I noticed you may be having trouble connecting your data source. What can I help you with?" : "Hi! I'm your AI assistant. Ask me anything about Nexus Cloud.";
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).
# Terminal 1 — Inkeep agents APIcd ~/inkeep-agentspnpm --filter agents-api dev# Terminal 2 — Bridgecd nexus-cloud/bridgeuv run uvicorn copilot_server:app --reload --port 8787# Terminal 3 — Frontendcd nexus-cloud/frontendnpm run dev
Then exercise the full flow:
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 — sendDataSourceOpen fires, the first data_source_open action 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_offer over 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?”
Path A — Show me how to fix the connection:
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 — guideStep 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 the session):
Click Open chat. The frontend fetches GET /context/{session_id} and gets the merged activity + stuck-connection summary.
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.”
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:
INFO: stored 1 action session=crm-demo-xxxINFO: stored 1 action session=crm-demo-xxxINFO: _detect_stuck_onboarding: firing offer_id=offer_abc session=crm-demo-xxxINFO: published guidance_offer to 1 subscriber(s) session=crm-demo-xxx
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/{session_id} returns "" — confirm (1) sendDataSourceOpen is sending the same session_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[session_id] is populated (bridge should log "stored N actions"), and (2) the session_id in sendDataSourceOpen matches the one passed to fetchContext.
PostHog SDK context is empty even after clicking around
context_store.get() was called without product_id=. Pass _session_product[session_id] on read — this was also covered in Step 1’s troubleshooting table.
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):
# bridge/proactive.pyTRIGGER_WINDOW_SECONDS = 120 # look-back window in secondsDATA_SOURCE_OPEN_THRESHOLD = 3 # number of opens before firing
Restart the bridge with --reload to pick up the change. The change only affects new sessions; any session that 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.
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. The Autoplay SDK pipeline (from Step 1) grounds reactive chat replies in real PostHog activity. The ProactiveState pipeline tracks explicit onboarding events for trigger detection. They coexist in copilot_server.py without interfering.
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/{session_id} endpoint returns a merge of SDK 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.