In Step 1 you built an Inkeep chat widget whose opening message is grounded in real user activity. This step makes it proactive — the bridge notices when a user is doing something the slow way and offers help before they ever open the chat. The concrete example catches the repetitive-action moment: a user adding clients one-by-one through a modal form, completely unaware that CSV bulk import exists. Three form opens in 90 seconds fires the offer: “Adding clients one by one? You can import them all at once with a CSV file — want me to show you how?” The user can reply yes in the chat input or click Show me — either path closes the chat and launches a four-step in-app tour that walks them through the CSV modal step by step. Plan to spend ~45 minutes the first time through. Every file is included verbatim.Documentation Index
Fetch the complete documentation index at: https://developers.autoplay.ai/llms.txt
Use this file to discover all available pages before exploring further.
End-to-end walkthrough
InkeepEmbeddedChat. Routing proactive messages through the widget breaks structurally: the socket might not be open, the session ID might not be resolved, and the UX (a closed chat icon with no indicator) doesn’t match the moment of friction. Inkeep, Intercom, and Drift all follow this pattern.📋 Before you start
This step picks up exactly where Step 1 left off. You should already have:bridge/main.pyrunning on:8787withAsyncConnectorClient,AsyncContextStore,AsyncSessionSummarizer,AsyncAgentContextWriter, andSessionStatewired togetherGET /context/{session_id}returning assembled activity context- Inkeep agents framework running on
:3002with a project, agent, and sub-agent configured InkeepEmbeddedChatmounted in your frontend and connecting successfully (anonymous session token works)
What you’ll build:
- Repetitive-action trigger detection in the bridge (≥ 3
client_addactions in 90 seconds). - An SSE endpoint the frontend subscribes to for real-time guidance events.
- A client-side action counter that fires the popup even without a running bridge.
- A proactive popup with two resolution paths: Show me and Open chat.
- A four-step in-app guide that walks the user through the CSV import modal.
InkeepEmbeddedChatopening with a pre-loaded context message.- An idle-timer fallback that fires after 20 seconds of no user activity.
🧰 Step 1 — Extend the bridge with trigger detection
Add these new imports at the top ofbridge/main.py:
bridge/main.py — constants and dataclasses (expand to copy)
bridge/main.py — constants and dataclasses (expand to copy)
InMemoryState.__init__ with two new fields:
publish_guidance as a method on InMemoryState:
bridge/main.py — trigger detection functions (expand to copy)
bridge/main.py — trigger detection functions (expand to copy)
handle_actions_payload to run the trigger on every incoming batch:
bridge/main.py — updated handle_actions_payload (expand to copy)
bridge/main.py — updated handle_actions_payload (expand to copy)
_detect_repetitive_edits to catch any pattern — repeated page bounces, long dwell on an error screen, rage-clicks, or a stalled multi-step wizard. The client_add action type is just one example.
📡 Step 2 — Add the SSE and reply routes
Add these three routes tobridge/main.py. They deliver guidance_offer and guidance_start events to the frontend in real time.
| Route | Direction | Purpose |
|---|---|---|
GET /guidance/stream/{session_id} | Bridge → Frontend | Real-time guidance events over SSE |
POST /demo/actions | Frontend → Bridge | Already exists from Step 1 — now also returns pending_offer |
POST /inkeep/reply | Frontend → Bridge | User accepted the offer; emits guidance_start |
bridge/main.py — new SSE and reply routes (expand to copy)
bridge/main.py — new SSE and reply routes (expand to copy)
demo_actions to return the pending offer so the frontend can store its ID:
type field | When | Frontend action |
|---|---|---|
guidance_offer | Bridge crossed the 3-click threshold | Show proactive popup |
guidance_start | User accepted offer via /inkeep/reply | Start the four-step guide |
🔥 Step 3 — Smoke test the trigger chain
Restart the bridge with--reload, then simulate three Add Client clicks for the same session:
"pending_offer": true. Now accept it:
guidance_start chain works. Now wire the frontend.
🔔 Step 4 — Subscribe to SSE on the frontend
Replace the minimalpage.tsx from Step 1 with the full proactive version. The key additions are:
addClientCountRef— auseRefcounter that increments on every Add Client open. UsinguseRefinstead ofuseStateavoids stale closures in the modal close handler.closeAddClientModal()— shared close handler that checks the count and fires the popup.- SSE
useEffect— subscribes toGET /guidance/stream/{sessionId}and sets state onguidance_offer/guidance_startevents. proactiveOfferstate — holds the active offer; drives the popup render.guideStepstate machine —"idle" | "step1" | "step2" | "step3" | "step4" | "done".
frontend/app/page.tsx — full listing with proactive layer (expand to copy)
frontend/app/page.tsx — full listing with proactive layer (expand to copy)
🎯 Step 5 — How the client-side trigger works
TheaddClientCountRef + closeAddClientModal() pattern fires the popup on the third modal close entirely in the browser — no bridge required.
frontend/app/page.tsx — client-side trigger handlers (expand to copy)
frontend/app/page.tsx — client-side trigger handlers (expand to copy)
🗺 Step 6 — The Show me path (four-step guide)
Clicking Show me callshandleShowMe(), which:
- If a bridge offer exists (
offerstate), sendsPOST /inkeep/reply→ bridge emitsguidance_start→ SSE setsguideStep = "step1". - If no bridge offer (client-side trigger only), sets
guideStep = "step1"directly.
guideStep state machine then drives the guide through four steps:
guideStep | What pulses | Tooltip text |
|---|---|---|
step1 | ”Import CSV” button | ”Click ‘Import CSV’ to bring in multiple clients at once.” |
step2 | ”Download template” button | ”Download the template first — it shows the required columns: name, email.” |
step3 | Upload drop zone | ”Drop your filled CSV here. All clients will be added in one go.” |
step4 | ”Import clients” button | ”Hit ‘Import clients’ — your entire list uploads instantly. That’s it!” |
done | Nothing | Guide complete |
crmBtn--pulse class adds a CSS ring animation to the button. Each GuideTooltip renders a floating card with a “Made with Usertour” footer.
Add the guide and popup CSS to frontend/app/styles.css:
styles.css — guide, pulse, and popup styles (expand to copy)
styles.css — guide, pulse, and popup styles (expand to copy)
💬 Step 7 — The Open chat path (pre-loaded context)
Clicking Open chat callshandleOpenChat(), which:
- Clears the idle timer.
- Dismisses the popup (
setProactiveOffer(null)). - Sets
chatOpen = true.
InkeepWidget receives chatOpen and contextMessage as props:
InkeepWidget, the key prop forces a full remount when chatOpen changes, which causes InkeepEmbeddedChat to reinitialize with a new introMessage:
The onInputMessageChange callback — detecting “yes” without a submit button
InkeepEmbeddedChat exposes aiChatSettings.onInputMessageChange, a callback that fires on every keystroke in the chat input. This lets the proactive flow detect intent the moment the user submits “yes” — no extra button, no polling, no backend configuration required.
Wire it in InkeepWidget.tsx:
"" (which happens after Enter is pressed to submit) and the last value was a “yes”-like phrase:
chatContextMessage is cleared, the listener is removed so normal chat is unaffected:
lastInputRef? onInputMessageChange fires inside a closure. If you stored the previous value in useState, the handler would capture a stale snapshot. A useRef always reads the live value, so YES_PHRASES.includes(lastInputRef.current) is always correct regardless of render timing.⏱ Step 8 — Idle-timer fallback
The bridge trigger fires after three Add Client opens, but users can also be stuck without clicking the button. The idle timer catches this:frontend/app/page.tsx — idle timer useEffect (expand to copy)
frontend/app/page.tsx — idle timer useEffect (expand to copy)
handleShowMe and handleOpenChat both call clearTimeout(idleTimer.current) to cancel the timer once the user engages.
Adjust IDLE_MS to your product’s rhythm. 20 seconds works well for a focused demo; a real onboarding flow might use 45–60 seconds to avoid interrupting users who are still reading.
✅ Step 9 — Run the full flow
Start all three services:- Open
http://localhost:3000. - Click + Add client three times, cancelling the modal each time.
- After the third cancel, the proactive popup appears bottom-right.
- Click Show me — the “Import CSV” button starts pulsing with a tooltip. Click it.
- The Import CSV modal opens. The “Download template” button pulses — click it.
- The upload drop zone pulses — click it.
- The “Import clients” button pulses — click it.
- Done. Guide complete.
- Reload the page, click + Add client three more times, then click Open chat instead.
- The chat overlay opens with a pre-loaded intro message about CSV import. Ask a question — the AI responds.
🛠 Troubleshooting
| Symptom | Likely cause |
|---|---|
| Popup never appears after 3 clicks | closeAddClientModal is not called on cancel — check that the overlay click handler and the Cancel button both call closeAddClientModal (not setAddClientOpen(false) directly). |
| Popup appears but Show me does nothing | handleShowMe calls acceptInkeepOffer which hits /inkeep/reply — check bridge logs for 404 (no pending offer). Make sure the bridge received all three client_add actions. |
guidance_start SSE arrives but guide doesn’t start | The SSE listener sets guideStep = "step1". Check that the listener is mounted (i.e., sessionId is non-empty before subscribing). |
| Chat opens but shows generic intro, not proactive message | proactiveOffer was cleared before handleOpenChat passed contextMessage to the widget. Move setProactiveOffer(null) to after the overlay is rendered, or capture the message in a ref before clearing. |
| Idle popup fires too quickly | IDLE_MS is too low, or resetIdle is not being called by the right DOM events. Add "touchstart" if on mobile. |
| Popup appears instantly on page load | resetIdle() is called in the useEffect body — the initial call starts the countdown immediately. This is correct. If you want to delay the first fire, only start the timer after the first user interaction. |
tsconfig error on @inkeep/agents-ui import | Add transpilePackages: ["@inkeep/agents-ui"] to next.config.ts. |
🔄 Further operations
--reload to pick up the change.
What you’ve built
- Dual-trigger proactive offers — the bridge detects repetitive Add Client clicks from live events and emits a
guidance_offerover SSE. A client-side ref counter fires the same popup even without the bridge running, making the demo robust in any environment. - Four-step in-app guide — clicking Show me walks the user through the Import CSV modal step by step, with pulsing buttons and inline tooltips at each stage.
- Pre-loaded AI chat — clicking Open chat opens
InkeepEmbeddedChatwith a contextual intro message that references exactly what the user was struggling with. - Idle-timer safety net — users who stall without clicking Add Client three times still get a proactive offer after 20 seconds of inactivity.
- Self-hosted AI — the Inkeep agents framework runs entirely on your infrastructure. LLM keys, conversation history, and system prompts never leave it.