Skip to main content

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:
  1. A custom proactive triggerbulk_edit_opportunity: 3+ “Save changes” clicks on the same list page within 120 seconds, where the user hasn’t already discovered bulk-edit.
  2. 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.
  3. A periodic driver — a small ProactiveDriver class that ticks every 10 seconds, pulls each active user’s recent activity over REST, and gates each potential offer through agent_state.v2.SessionState (now keyed by user_id).
  4. A host-app toast surface — an SSE endpoint on the bridge + a <ProactiveToast /> component in your Next.js app.
  5. Two CTAs on every toastShow me (Usertour flow) or Open chat (Rasa bubble + LLM auto-followup grounded by per-trigger ground-truth text).
  6. FSM-driven tour stateSessionState.set_visual_guidance and record_tour_step keep the bridge in sync during a tour.
The runtime loop:
The customer story the demo proves: a user on /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.
Proactive is a host-app surface, not a chat-vendor surface. The toast lives in your Next.js layout, not inside 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.py running on :8090 with fetch_recent_activity_raw / fetch_recent_activity_text (the REST live-activity pull) and SessionState wired together
  • _user_states, _user_emails dicts — note there’s no session index anymore; everything the bridge tracks is already keyed by user_id
  • _user_state(user_id) factory returning a SessionState(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 1 domain.yml, data/*.yml, and actions/actions.py
  • app/posthog-provider.js, app/rasa-widget.js mounted in app/layout.js
The code blocks below extend those files — they don’t replace them.
What’s different from the old build. Step 1 no longer accumulates actions locally — there’s no 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)

The bulk_edit_opportunity trigger fires from a real behavioral pattern, so the recipe assumes your app has a list page where:
  1. Users can edit individual items via a button labelled “Edit” that opens a dialog with a “Save changes” button.
  2. 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.
Swap our /projects example for your own list page. What the recipe needs is stable DOM IDs on the elements the tour will spotlight:
Demo IDWhat it isTour step
#projects-table-optionsThe kebab (⋯) icon at the top of the table1
#projects-bulk-edit-toggleThe “Bulk edit” dropdown menu item inside the kebab2
#project-checkbox-{rowId}Per-row checkbox in bulk mode (e.g. #project-checkbox-P-1042)3
#bulk-applyThe “Apply to N” button in the floating toolbar4
#edit-saveThe single-edit dialog’s “Save changes” button — fires the trigger predicaten/a (predicate input)
The kebab SVG needs pointer-events-none. If you wrap a Lucide / Heroicon SVG inside a <button>, the click event’s event.target is the <svg>, not the button — and Usertour’s “advance on click” trigger matches against the registered anchor’s element. The tour appears to ignore your clicks. Fix: <MoreHorizontal className="pointer-events-none h-4 w-4" />. Cost us ~15 minutes the first time.

🧠 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.
timestamp_start is a string, not a number, and the format varies. PostHog destinations send UNIX-epoch strings ("1779188762.919"); other Autoplay destinations send ISO-8601 ("2026-05-19T11:07:19.847Z"). The _action_start_dt helper tolerates both — skip it and your predicate silently returns None and the trigger never fires.
Why match on 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 of proactive.py:
transition_to_proactive raises from non-THINKING states. The docs frame it as returning False on cooldown, but that’s only true from THINKING. Always guard with if state.current_state != AgentStateV2.THINKING: continue before the transition call.

🔌 Step 3 — Wire the driver into the bridge

Extend bridge/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.
Why 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.

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.
Without ground-truth _TRIGGER_HOW_TO, the LLM hallucinates UI. Our first auto-followup confidently told the user to “click Edit Selected at the top” — a button that doesn’t exist. Always pass the authoritative steps when synthesising on the user’s behalf.

3e. _deliver_to_app — fan out the trigger to the host app

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.
Go back to the app = FastAPI(title="Autoplay × Rasa bridge") line from Step 1 and wire it up:
Restart the bridge. The startup log should show:

🛣️ 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:
Without the SPA pageview hook your trigger predicate looks for /projects in canonical_url and finds nothing. PostHog only auto-fires the initial pageview on first load, never on subsequent client-side navigations.
Without the /track/{user_id} calls, the toast never fires — even with activity flowing. The driver only polls users returned by _get_active_users(); a user who’s never pinged /track is invisible to it, no matter how much activity the connector has recorded for them.
phc_ vs phx_ keys. Use the Project API key (starts with phc_). The other keys PostHog surfaces (phx_…) are personal/admin keys — posthog.init() will reject them with 401 + "API key is not valid: personal_api_key".

🍞 Step 5 — Render the toast in your Next.js app

Create app/proactive-toast.js. Subscribes to the SSE stream on mount, renders each event as a dismissible bubble bottom-right, surfaces two CTAs.
This file references two helpers we’ll add in Steps 6 and 7 (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.
Mount it once in 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:
  1. Push the proactive message into the Rasa conversation as a bot bubble (not a generic /greet).
  2. 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_TO text from Step 3d.
The user lands in the open chat with the question + answer already there — no typing required. Rasa exposes 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):
Add a rule to rasa-bot/data/rules.yml:
Add a single dummy NLU example so Rasa knows the intent exists (the real trigger comes from the bridge’s trigger_intent API):
Add ActionSendProactive to rasa-bot/actions/actions.py:
Retrain Rasa and restart:
Drop the greet rule (or guard the bridge call). Rasa’s action_listen cycle delivers empty user events on session bootstrap and widget reconnects — those would otherwise classify as the closest text intent (often greet) and trigger a generic “Hi, I’m your assistant!” bubble in front of the proactive message. Either remove the greet intent/rule from Step 1, or guard every action with if not query.strip(): return.

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.

6c. Frontend — injectIntoChat + widget setup

Add to the top of app/proactive-toast.js:
And in 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:

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.
Simpler than the old build. There’s no _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).
The Rasa session ID lives in sessionStorage, keyed by chat_sessionsession_id. That’s the value Rasa’s SocketIO channel uses as sender_id — different from your auth user ID. If you pass your auth user ID to trigger_intent, the call returns 200 OK but the message never reaches the widget.

🗺️ 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

  1. Create a free Usertour account at usertour.io.
  2. Settings → Environments → copy the Usertour.js token (public, frontend-safe).
  3. Flows → New flow → name it projects-bulk-edit. Open http://localhost:3000/projects in another tab so Usertour’s element picker has the live page to target.
  4. Build 4 steps on the /projects page targeting these selectors:
    #Step nameCSS selectorTooltip textAdvance trigger
    1Open table options#projects-table-optionsClick the ⋯ icon to open the table-options menu — bulk edit hides here.Click on element → step 2
    2Pick Bulk edit#projects-bulk-edit-togglePick “Bulk edit” — checkboxes will appear on every row.Click on element → step 3
    3Select 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
    4Apply#bulk-applyPick the new priority in the floating toolbar, then click here — all selected projects update at once.Click on element → Dismiss flow
  5. Settings:
    • Auto-start: OFF — we trigger this programmatically from the toast.
    • Skippable: ON.
    • Theme: whatever you prefer.
  6. Publish the flow.
  7. Copy the flow ID from the URL — app.usertour.io/.../flows/<FLOW_ID>/detail. You’ll need this in Step 7c.
Step 2’s anchor lives in a Radix portal. #projects-bulk-edit-toggle is inside a dropdown menu that renders to a React portal. Usertour’s selector targeting should still find it by id, but if step 2 fails to attach: (Fix A) set the step’s placement to “floating / freeform” so the tooltip isn’t tied to the element’s position, or (Fix B) collapse steps 1+2 into a single “Click ⋯ → Bulk edit” tooltip that advances on any descendant click.

7b. Wire the Usertour SDK into your app

Create app/usertour-provider.js:
Mount in app/layout.js:

7c. Bridge — TourRegistry + lifecycle endpoints

Extend copilot_server.py:
No more “no_session” case. _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:

🧭 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:
Array order is not the same as “most recent.” The live-activity endpoint documents oldest → newest ordering, but don’t hard-code an assumption on top of an assumption — pick explicitly by timestamp_start so a future ordering change (or a client that doesn’t guarantee it) can’t silently point the tour at a stale page.

✅ Step 9 — End-to-end test

  1. Make sure the bridge, Rasa, action server, and Next.js dev server are all running.
  2. Open an incognito window (clean PostHog session, no stale chat_session) → http://localhost:3000/projects.
  3. Click around briefly (one click is enough to register the SSE subscriber).
  4. 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.
  5. Wait ~10–30 seconds for the next poll tick. A toast appears bottom-right with two CTAs: Show me and Open chat.
  6. 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.
  7. 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.
In the bridge log you should see, in order:

🛠 Troubleshooting

SymptomLikely cause
Toast never fires, and the bridge never even logs a tick for this userThe 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 THINKINGPredicate 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 ticksSame 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 oncePredicate 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 logMissing if state.current_state != AgentStateV2.THINKING: continue before the transition call.
Toast UI doesn’t appear but published subscribers=N>0 is loggedFrontend SSE connection dropped — check Network tab for the /proactive/stream/{user_id} request.
Toast UI doesn’t appear and subscribers=0No tab is open with the demo app. Open one.
Open chat works but no bot bubble appearsWrong 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 / hallucinatedMissing or empty _TRIGGER_HOW_TO entry for the trigger. Add the authoritative steps; restart the bridge.
Show me button doesn’t appear on the toastTour-gating filtered it out — _current_page_hint returned something that didn’t contain /projects.
Usertour spotlight ignores clicks on the kebabThe 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 attachRadix portal — see the Warning in Step 7a. Set step 2’s placement to “floating” OR collapse steps 1+2.
Usertour identify error in the consoleToken mismatch — verify the env token matches Settings → Environments → Production.
Toast fires repeatedly even after user accepts the tourThe 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 messageA 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_urlSPA pageviews not captured — Step 4 hooks usePathname and calls posthog.capture("$pageview", ...).
Trigger fires 3 minutes latePostHog client-side batching is still on. Set request_batching: false (Step 4).

🔄 Day-2 operations

Same restart rules as Step 1: 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 by agent_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 ProactiveDriver asks 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_opportunity is 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.
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.