End-to-end walkthrough
In Step 1 you built a Rasa bot that gives reactive answers grounded in real activity. This page makes it proactive — the bot notices when a user is doing something the slow way and offers help without waiting to be asked. The concrete example we’ll build catches the bulk-edit moment: a user editing items one-by-one on a list page, unaware that a multi-select bulk-edit exists. The bot interrupts politely with a toast that offers two paths — Show me (visual tour) or Open chat (proactive bot message + LLM follow-up grounded in your app’s UI). Plan to spend ~45 minutes the first time through. What you’ll add to the Step 1 build:
- A custom proactive trigger —
bulk_edit_opportunity: 3+ “Save changes” clicks on the same list page within 120 seconds, where the user hasn’t already discovered bulk-edit. - A tiny “who’s active” signal — a
POST /track/{user_id}the frontend fires on mount/pageview, so the driver knows which users to poll. - A periodic driver — a small
ProactiveDriverclass that ticks every 10 seconds, pulls each active user’s recent activity over REST, and gates each potential offer throughagent_state.v2.SessionState(now keyed byuser_id). - A host-app toast surface — an SSE endpoint on the bridge + a
<ProactiveToast />component in your Next.js app. - Two CTAs on every toast — Show me (Usertour flow) or Open chat (Rasa bubble + LLM auto-followup grounded by per-trigger ground-truth text).
- FSM-driven tour state —
SessionState.set_visual_guidanceandrecord_tour_stepkeep the bridge in sync during a tour.
/projects bumps the priority of three projects one-by-one. ~30 seconds in, a toast appears: “Updating projects one by one? There’s a multi-select bulk edit you might not have seen — want me to show you?” The user picks Show me or Open chat — one trigger, two paths, one chat widget, chosen by the user.
rasa-webchat. This is what Intercom, Drift, and Botpress all do — and it’s the only architecture that lets the proactive nudge appear when the chat is closed. Routing proactive messages through the chat widget breaks structurally: the socket isn’t always open, the session ID isn’t always known, and the UX (a closed chat icon with no indicator) doesn’t match the moment.📋 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:8090withfetch_recent_activity_raw/fetch_recent_activity_text(the REST live-activity pull) andSessionStatewired together_user_states,_user_emailsdicts — note there’s no session index anymore; everything the bridge tracks is already keyed byuser_id_user_state(user_id)factory returning aSessionState(session_id=user_id, interaction_timeout_s=10.0, cooldown_period_s=20.0)(short demo values; production should use ~60s / ~120s)rasa-bot/with the Step 1domain.yml,data/*.yml, andactions/actions.pyapp/posthog-provider.js,app/rasa-widget.jsmounted inapp/layout.js
on_actions callback and no context_store to reach into. This step’s driver instead needs to know which users are currently active (Step 2 below adds a tiny /track/{user_id} endpoint for that) and pulls each one’s raw actions from the REST endpoint on every tick, instead of reading them out of a local buffer. Everything else — the trigger predicate, the FSM, the toast, the tour — is unchanged; only where the raw actions come from is different.Frontend prerequisites (the UI the toast and tour will target)
Thebulk_edit_opportunity trigger fires from a real behavioral pattern, so the recipe assumes your app has a list page where:
- Users can edit individual items via a button labelled “Edit” that opens a dialog with a “Save changes” button.
- There is a (genuinely hidden) bulk-edit feature behind a kebab/options menu — selecting it puts the table into “checkbox mode” so the user can multi-select and apply a change to many rows at once.
/projects example for your own list page. What the recipe needs is stable DOM IDs on the elements the tour will spotlight:
| Demo ID | What it is | Tour step |
|---|---|---|
#projects-table-options | The kebab (⋯) icon at the top of the table | 1 |
#projects-bulk-edit-toggle | The “Bulk edit” dropdown menu item inside the kebab | 2 |
#project-checkbox-{rowId} | Per-row checkbox in bulk mode (e.g. #project-checkbox-P-1042) | 3 |
#bulk-apply | The “Apply to N” button in the floating toolbar | 4 |
#edit-save | The single-edit dialog’s “Save changes” button — fires the trigger predicate | n/a (predicate input) |
🧠 Step 1 — Add the proactive trigger
Inside~/your-copilot/bridge/, create proactive.py. This is where the trigger lives — a single PredicateProactiveTrigger plus the helpers it needs.
proactive.py — predicate + registry (expand to copy)
proactive.py — predicate + registry (expand to copy)
action.title, not selectors. PostHog autocapture computes a human-readable title from the clicked element’s text — much more stable than a CSS selector or attr__id. If your “save” button uses different text in different places (“Update” vs “Save”), use a list of hints and any(...) over them.⚙️ Step 2 — Add the driver loop
The SDK ships the registry, types, and FSM — but no driver. Every customer doing proactive ends up writing this same loop, so we keep it explicit in your own code. Add this to the bottom ofproactive.py:
proactive.py — ProactiveDriver class (expand to copy)
proactive.py — ProactiveDriver class (expand to copy)
🔌 Step 3 — Wire the driver into the bridge
Extendbridge/copilot_server.py from Step 1. Six additions.
3a. Imports and constants
3b. Track active users, then build a ProactiveTriggerContext from the REST pull
The old build only had to look at sessions it already knew about — AsyncContextStore filled itself in from the SSE push. The pull model flips this: nothing arrives unless the bridge asks, so it first needs to know which users are worth asking about. A tiny endpoint the frontend pings on mount/pageview covers that; the driver then polls each one over the REST endpoint from Step 1.
Add these near the bottom of the file, before the FastAPI route handlers.
copilot_server.py — active-user tracking + proactive context (expand to copy)
copilot_server.py — active-user tracking + proactive context (expand to copy)
session_id=user_id. ProactiveTriggerContext.from_slim_actions validates scope with ScopePolicy.STRICT by default, which requires a non-empty product_id and session_id. Since the REST read has no session concept, reusing user_id there satisfies the check without inventing a fake session. SlimAction.from_dict also accepts (and ignores) fields it doesn’t recognise, and defaults session_id/user_id/email on the action itself to None — none of which the predicate in Step 1 reads.3c. Per-user SSE fan-out (the delivery channel)
The proactive driver writes events into a per-user queue. Any open tab subscribes to/proactive/stream/{user_id} and drains its own copy.
copilot_server.py — SSE fan-out + endpoint (expand to copy)
copilot_server.py — SSE fan-out + endpoint (expand to copy)
3d. Per-trigger message + how-to ground truth
The same trigger fires both surfaces — the toast and (if Open chat) the LLM follow-up. We keep one canonical body per trigger, plus a separate “how-to” string fed to the LLM as ground truth so the auto-followup doesn’t invent button names.copilot_server.py — _PROACTIVE_MESSAGES + _TRIGGER_HOW_TO (expand to copy)
copilot_server.py — _PROACTIVE_MESSAGES + _TRIGGER_HOW_TO (expand to copy)
3e. _deliver_to_app — fan out the trigger to the host app
copilot_server.py — _deliver_to_app (expand to copy)
copilot_server.py — _deliver_to_app (expand to copy)
3f. Start the driver in a lifespan
Step 1’s bridge has no lifespan — there was nothing to open or close. The proactive driver needs one, purely to start/stop its background polling task. Add it, then point app = FastAPI(...) at it.
copilot_server.py — lifespan with driver (expand to copy)
copilot_server.py — lifespan with driver (expand to copy)
app = FastAPI(title="Autoplay × Rasa bridge") line from Step 1 and wire it up:
🛣️ Step 4 — Capture SPA pageviews (and disable batching)
<Link> navigations in Next.js use pushState and don’t fire automatic $pageview events. Without manual capture, the bridge never learns the user is on /projects — your predicate looks for /projects in canonical_url and finds nothing.
Two more tweaks: disable PostHog client-side batching (so events ship immediately, not on a 30s window), and capture pageleave too. We also add the /track/{user_id} ping from Step 3b here — it’s the signal that tells the bridge’s ProactiveDriver this user exists to poll.
Update app/posthog-provider.js:
app/posthog-provider.js — full file (expand to copy)
app/posthog-provider.js — full file (expand to copy)
🍞 Step 5 — Render the toast in your Next.js app
Createapp/proactive-toast.js. Subscribes to the SSE stream on mount, renders each event as a dismissible bubble bottom-right, surfaces two CTAs.
injectIntoChat and startTour). Until then the buttons throw ReferenceError on click — the toast itself appears and auto-dismisses, but the CTAs don’t work yet.app/proactive-toast.js — toast component (expand to copy)
app/proactive-toast.js — toast component (expand to copy)
app/layout.js (next to your existing providers):
💬 Step 6 — The “Open chat” path with LLM auto-followup
When the user clicks Open chat on the toast, two things happen in sequence:- Push the proactive message into the Rasa conversation as a bot bubble (not a generic
/greet). - Treat the click as an implicit “yes” and automatically follow up with an LLM-generated step-by-step, grounded in the per-trigger
_TRIGGER_HOW_TOtext from Step 3d.
POST /conversations/{sender}/trigger_intent?output_channel=socketio. Pair it with the widget’s existing socket session ID (which rasa-webchat stores in sessionStorage.chat_session.session_id) and you can inject a typed bot utterance into the open SocketIO channel.
6a. Rasa — accept an EXTERNAL_proactive intent
Update rasa-bot/domain.yml (additive to Step 1):
rasa-bot/data/rules.yml:
trigger_intent API):
ActionSendProactive to rasa-bot/actions/actions.py:
6b. Bridge — /proactive/inject with LLM auto-followup
Add to copilot_server.py. We reuse the _build_assembly and SYSTEM_PROMPT from Step 1 to keep the LLM grounded in the same activity context the reactive /reply endpoint uses.
copilot_server.py — _push_bot_message + _llm_reply (expand to copy)
copilot_server.py — _push_bot_message + _llm_reply (expand to copy)
copilot_server.py — /proactive/inject endpoint (expand to copy)
copilot_server.py — /proactive/inject endpoint (expand to copy)
6c. Frontend — injectIntoChat + widget setup
Add to the top of app/proactive-toast.js:
app/rasa-widget.js — clear the chat session on each fresh page load so stale conversations don’t bleed in, and (if you copied the Step 1 widget verbatim) drop the initPayload: "/greet" so the chat doesn’t double up with a generic greeting in front of the proactive message:
app/rasa-widget.js — updated widget (expand to copy)
app/rasa-widget.js — updated widget (expand to copy)
6d. Bridge — also call _mark_reactive on /reply
In Step 1’s /reply endpoint, add a single call that flips the FSM into REACTIVE for the user whenever they send a chat message. This prevents new proactive triggers from interrupting mid-conversation. Since the FSM is keyed by user_id directly (Step 1, section 2c), this no longer needs a session lookup first.
copilot_server.py — _mark_reactive (expand to copy)
copilot_server.py — _mark_reactive (expand to copy)
_latest_session_for_user step anymore — the old version had to look up “which session is this user’s most recent one” before it could find the right SessionState, because the FSM was keyed by session_id. Now the FSM is keyed by user_id directly, so _mark_reactive (and the tour endpoints in Step 7) go straight to _user_state(user_id).🗺️ Step 7 — The “Show me” path with Usertour
For the visual-tour CTA we use Usertour — open-source, free cloud tier, and the Autoplay SDK already ships a typed helper for its events (autoplay_sdk.integrations.usertour_sse).
If you’d prefer a different tool (Userpilot, Chameleon, Userflow, Pendo, UserGuiding, Told), the same wiring applies — only the SDK init/identify call changes.
7a. Sign up + build the flow
- Create a free Usertour account at usertour.io.
- Settings → Environments → copy the Usertour.js token (public, frontend-safe).
-
Flows → New flow → name it
projects-bulk-edit. Openhttp://localhost:3000/projectsin another tab so Usertour’s element picker has the live page to target. -
Build 4 steps on the
/projectspage targeting these selectors:# Step name CSS selector Tooltip text Advance trigger 1 Open table options #projects-table-optionsClick the ⋯ icon to open the table-options menu — bulk edit hides here. Click on element → step 2 2 Pick Bulk edit #projects-bulk-edit-togglePick “Bulk edit” — checkboxes will appear on every row. Click on element → step 3 3 Select projects #project-checkbox-P-1042(pick any row id from your seed data)Tick the projects you want to update. Pick a few — five works for a quick demo. Click on element → step 4 4 Apply #bulk-applyPick the new priority in the floating toolbar, then click here — all selected projects update at once. Click on element → Dismiss flow -
Settings:
- Auto-start: OFF — we trigger this programmatically from the toast.
- Skippable: ON.
- Theme: whatever you prefer.
- Publish the flow.
-
Copy the flow ID from the URL —
app.usertour.io/.../flows/<FLOW_ID>/detail. You’ll need this in Step 7c.
7b. Wire the Usertour SDK into your app
app/usertour-provider.js:
app/usertour-provider.js — full file (expand to copy)
app/usertour-provider.js — full file (expand to copy)
app/layout.js:
7c. Bridge — TourRegistry + lifecycle endpoints
Extend copilot_server.py:
copilot_server.py — TourRegistry + tour-payload helper (expand to copy)
copilot_server.py — TourRegistry + tour-payload helper (expand to copy)
copilot_server.py — /tour/start, /tour/step, /tour/end (expand to copy)
copilot_server.py — /tour/start, /tour/step, /tour/end (expand to copy)
_user_state(user_id) lazily creates a SessionState the first time it’s asked for a given user (Step 1, section 2c), so there’s no longer a way to have a user_id with no matching state to look up.7d. Frontend — startTour helper
Add to the top of app/proactive-toast.js:
app/proactive-toast.js — startTour helper (expand to copy)
app/proactive-toast.js — startTour helper (expand to copy)
🧭 Step 8 — Current-page hint (the tour-gating fix)
_tour_payload_for_trigger (Step 7c) needs to know which page the user is currently on so it can decide whether to offer the tour at all (no point spotlighting /projects anchors when the user is on /dashboard).
Add this helper to copilot_server.py:
copilot_server.py — _current_page_hint (expand to copy)
copilot_server.py — _current_page_hint (expand to copy)
✅ Step 9 — End-to-end test
- Make sure the bridge, Rasa, action server, and Next.js dev server are all running.
- Open an incognito window (clean PostHog session, no stale
chat_session) →http://localhost:3000/projects. - Click around briefly (one click is enough to register the SSE subscriber).
- Edit three different projects’ priorities one by one:
- Click Edit on project A → change Priority to High → Save changes.
- Click Edit on project B → change Priority to High → Save changes.
- Click Edit on project C → change Priority to High → Save changes.
- Wait ~10–30 seconds for the next poll tick. A toast appears bottom-right with two CTAs: Show me and Open chat.
- Path A — Show me. Click it. Usertour spotlights the ⋯ kebab → click → “Bulk edit” → click → checkboxes appear → tick rows → spotlight moves to Apply to N → click → tour ends, all selected projects updated.
- Path B — Open chat. In a fresh tab (so the FSM cooldown doesn’t block), repeat steps 3–5. Click Open chat. The Rasa widget opens. Bubble 1: the proactive question. ~0.6s later, bubble 2: an LLM-generated step-by-step grounded in your
_TRIGGER_HOW_TO.
🛠 Troubleshooting
| Symptom | Likely cause |
|---|---|
| Toast never fires, and the bridge never even logs a tick for this user | The user never pinged /track/{user_id} — check Step 4’s trackActive() is wired into both loaded and the pageview effect, and that it’s reaching the bridge (Network tab). |
Toast never fires, FSM stays in THINKING | Predicate is returning False. Add a debug log in the predicate and check recent_actions — likely no /projects URL (missing SPA pageview hook from Step 4), or fewer than 3 “Save changes” titles within 120s, or the user already clicked Bulk edit / Apply earlier in the session. |
Bridge logs live-activity fetch 401/timeout during proactive ticks | Same MCP_KEY / CONNECTOR_URL misconfiguration as Step 1 — the driver uses the same fetch_recent_activity_raw the /reply endpoint does. |
Trigger fires on every page after the user visits /projects once | Predicate doesn’t gate on current page. The recipe’s predicate has the “latest action by timestamp must be a /projects page-view” guard — make sure it’s still there. |
InvalidTransitionError from transition_to_proactive in the log | Missing if state.current_state != AgentStateV2.THINKING: continue before the transition call. |
Toast UI doesn’t appear but published subscribers=N>0 is logged | Frontend SSE connection dropped — check Network tab for the /proactive/stream/{user_id} request. |
Toast UI doesn’t appear and subscribers=0 | No tab is open with the demo app. Open one. |
Open chat works but no bot bubble appears | Wrong sender_id — read sessionStorage.chat_session.session_id (the Rasa/SocketIO chat session, unrelated to Autoplay activity), not the auth user ID. |
Open chat works but the auto-followup is generic / hallucinated | Missing or empty _TRIGGER_HOW_TO entry for the trigger. Add the authoritative steps; restart the bridge. |
Show me button doesn’t appear on the toast | Tour-gating filtered it out — _current_page_hint returned something that didn’t contain /projects. |
| Usertour spotlight ignores clicks on the kebab | The Lucide / Heroicon SVG inside the button has pointer-events enabled. Add className="pointer-events-none" to the icon. |
| Usertour step 2 (Bulk edit dropdown item) doesn’t attach | Radix portal — see the Warning in Step 7a. Set step 2’s placement to “floating” OR collapse steps 1+2. |
| Usertour identify error in the console | Token mismatch — verify the env token matches Settings → Environments → Production. |
| Toast fires repeatedly even after user accepts the tour | The FSM cooldown is shorter than your test loop. Demo uses 10s/20s; production should be 60s/120s or longer. |
| Chat opens with a generic “Hi!” bubble in front of the proactive message | A greet rule is still firing on empty action_listen cycles. Remove the greet intent/rule, or guard _reply_via_bridge with if not query.strip(): return. |
/projects URL never appears in canonical_url | SPA pageviews not captured — Step 4 hooks usePathname and calls posthog.capture("$pageview", ...). |
| Trigger fires 3 minutes late | PostHog client-side batching is still on. Set request_batching: false (Step 4). |
🔄 Day-2 operations
docker compose restart action-server after actions.py changes, docker compose run --rm rasa train && docker compose restart rasa after any .yml changes, --reload the bridge during development.
What you’ve built
You now have a Rasa support AI agent that goes proactive and visual — driven by the SDK’s trigger registry, gated byagent_state.v2.SessionState, surfaced through a host-app toast that gives the user a real choice between show me and tell me.
A few things worth noting:
- The driver polls instead of listening. With a pull-based connector there’s no push to react to, so the
ProactiveDriverasks each active user’s activity on a timer instead. Everything downstream — the predicate, the FSM, the toast, the tour — is exactly the same shape it would be with a push source. - The toast is owned by your app, not the chat vendor. That’s the only architecture that lets proactive nudges appear when the chat widget is closed — which is the only state most users spend their time in.
- Triggers are just predicates.
bulk_edit_opportunityis one rule; you can compose dozens. The SDK ships built-ins for common signals (canonical_url_ping_pong,user_page_dwell,section_playbook_match) — combine them in your registry. - The visual layer is yours. Usertour is one option among many (Userpilot, Chameleon, Userflow, Pendo, UserGuiding, Told). The SDK gives you the state and the trigger; the renderer is plug-in.
- The LLM auto-followup turns “Open chat” into a real conversation, not a dead-end. Without the ground-truth text the same path produces confident hallucinations; with it, the bot stays accurate to your actual UI.