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:
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:
  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


🔄 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.