Make your Rasa copilot proactive — detect when a user is using the slow path of a workflow and surface a toast with two CTAs: ‘Show me’ (visual tour via Usertour) and ‘Open chat’ (proactive bot message with an LLM-grounded follow-up).
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 periodic driver — a small ProactiveDriver class that ticks every 10 seconds and gates each potential offer through agent_state.v2.SessionState.
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_guidance and record_tour_step keep the bridge in sync during a tour.
The runtime loop:
PostHog events ──► Bridge (Step 1 pipeline + new ProactiveDriver) │ ▼ Trigger fires → SSE → Toast appears (chat still closed) │ ┌────────────┴────────────┐ ▼ ▼ "Show me" "Open chat" │ │ ▼ ▼ Usertour flow Rasa trigger_intent ×2: spotlights UI (1) proactive question step by step (2) LLM auto-followup, grounded in per-trigger how-to text
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.
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 AsyncConnectorClient, AsyncContextStore, AsyncSessionSummarizer, AsyncAgentContextWriter, and SessionState wired together
_user_sessions, _session_product, _session_states dicts populated by on_actions
_session_state(session_id) factory returning a SessionState(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.
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:
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.
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 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)
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.
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 triggers for the Rasa copilot.Single custom trigger: `bulk_edit_opportunity` — fires when the user isediting list items one-by-one and the SDK detects the pattern. The bot thenoffers to walk the user through the (hidden) bulk-edit feature.This is the SDK's actual value-prop in miniature: a multi-signal patternacross pages and time (3+ Save-changes clicks within a short window) thatno single page or `setTimeout` could catch.Delivery contract: the caller passes an async`deliver(session_id, user_id, ProactiveTriggerResult)` coroutine that thedriver invokes when the trigger fires AND the FSM allows it."""from __future__ import annotationsimport asyncioimport loggingfrom collections.abc import Awaitable, Callablefrom datetime import datetime, timedelta, timezonefrom typing import Iterablefrom autoplay_sdk.agent_state.v2.states import AgentStateV2, SessionStatefrom autoplay_sdk.proactive.triggers import ( PredicateProactiveTrigger, ProactiveTriggerContext, ProactiveTriggerRegistry, ProactiveTriggerResult,)log = logging.getLogger("copilot.proactive")TRIGGER_ID_BULK_EDIT_OPPORTUNITY = "bulk_edit_opportunity"# Substring matched against `action.canonical_url` (case-insensitive). Swap# for your own list page._PROJECTS_URL_FRAGMENT = "/projects"# Substring matched against `action.title` — the human-readable label PostHog# autocapture computes from the clicked element's text content._SAVE_CLICK_HINT = "save changes"_BULK_TOGGLE_HINT = "bulk edit"_MIN_SAVES = 3_WINDOW_SECONDS = 120.0def _is_projects_page(action) -> bool: return _PROJECTS_URL_FRAGMENT in (action.canonical_url or "").lower()def _is_save_changes_click(action) -> bool: return _SAVE_CLICK_HINT in (action.title or "").lower()def _is_bulk_toggle_click(action) -> bool: """If the user has already discovered bulk-edit in this session, don't nudge them about it.""" title = (action.title or "").lower() return _BULK_TOGGLE_HINT in title or "apply to" in titledef _action_start_dt(action) -> datetime | None: """SlimAction.timestamp_start → tz-aware datetime. The SDK ships timestamps in two flavors depending on the source: - UNIX epoch as a numeric string ("1779188762.919") ← PostHog destination - ISO-8601 ("2026-05-19T11:07:19.847Z") ← other sources Handle both. """ raw = getattr(action, "timestamp_start", None) if raw is None or raw == "": return None if isinstance(raw, datetime): return raw if raw.tzinfo else raw.replace(tzinfo=timezone.utc) s = str(raw).strip() try: return datetime.fromtimestamp(float(s), tz=timezone.utc) except (TypeError, ValueError): pass try: dt = datetime.fromisoformat(s.replace("Z", "+00:00")) return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc) except (TypeError, ValueError): return Nonedef _latest_action_by_timestamp(actions): """Return the action with the largest timestamp_start, or None. PostHog batches don't always arrive in chronological order — `actions[-1]` is NOT reliably the most recent action.""" latest, latest_ts = None, float("-inf") for a in actions: try: ts = float(a.timestamp_start) if a.timestamp_start else float("-inf") except (TypeError, ValueError): ts = float("-inf") if ts > latest_ts: latest_ts, latest = ts, a return latestdef _bulk_edit_opportunity_predicate(ctx: ProactiveTriggerContext) -> bool: """Fire when the user is editing list items one-by-one. Logic: 1. The user must currently be on the list page (so the toast/tour land in context). If they wandered off, no nudge. 2. The user must NOT have already discovered the bulk feature in the recent window. 3. There must be ≥ _MIN_SAVES "Save changes" clicks on the list page within the trailing _WINDOW_SECONDS. """ actions = ctx.recent_actions if not actions: return False latest = _latest_action_by_timestamp(actions) if latest is None or not _is_projects_page(latest): return False if any(_is_bulk_toggle_click(a) for a in actions): return False cutoff = datetime.now(timezone.utc) - timedelta(seconds=_WINDOW_SECONDS) save_count = 0 for a in actions: if not _is_projects_page(a): continue if not _is_save_changes_click(a): continue ts = _action_start_dt(a) if ts is None or ts < cutoff: continue save_count += 1 if save_count >= _MIN_SAVES: return True return Falsedef build_registry() -> ProactiveTriggerRegistry: """One custom trigger. The SDK ships built-ins (`canonical_url_ping_pong`, `user_page_dwell`, `section_playbook_match`) you can compose alongside — see the [authoring guide](/sdk/proactive-triggers-authoring) for examples.""" bulk_edit = PredicateProactiveTrigger( trigger_id=TRIGGER_ID_BULK_EDIT_OPPORTUNITY, body=( "Updating projects one by one? There's a multi-select bulk edit " "you might not have seen — want me to show you?" ), predicate=_bulk_edit_opportunity_predicate, ) return ProactiveTriggerRegistry([bulk_edit])
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.
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:
proactive.py — ProactiveDriver class (expand to copy)
# ---------------------------------------------------------------------------# Tick driver# ---------------------------------------------------------------------------DeliverFn = Callable[[str, str, ProactiveTriggerResult], Awaitable[None]]SessionContextFn = Callable[[str, str], ProactiveTriggerContext | None]SessionStateFn = Callable[[str], SessionState]UserSessionsFn = Callable[[], Iterable[tuple[str, str]]]class ProactiveDriver: """Periodic tick loop that walks tracked sessions and fires triggers.""" def __init__( self, *, registry: ProactiveTriggerRegistry, get_sessions: UserSessionsFn, get_context: SessionContextFn, get_state: SessionStateFn, deliver: DeliverFn, poll_seconds: float = 10.0, ) -> None: self._registry = registry self._get_sessions = get_sessions self._get_context = get_context self._get_state = get_state self._deliver = deliver self._poll_seconds = poll_seconds self._task: asyncio.Task | None = None self._running = False async def _tick_once(self) -> None: for user_id, session_id in self._get_sessions(): try: state = self._get_state(session_id) state.tick() # auto-exit PROACTIVE/REACTIVE on timeout ctx = self._get_context(user_id, session_id) if ctx is None: continue result = self._registry.evaluate_first(ctx) if result is None: continue # FSM gate. `transition_to_proactive` raises # InvalidTransitionError from non-THINKING states. if state.current_state != AgentStateV2.THINKING: continue if not state.transition_to_proactive(result.trigger_id): continue # cooldown blocked it log.info( "proactive: firing session=%s user=%s trigger=%s", session_id, user_id, result.trigger_id, ) await self._deliver(session_id, user_id, result) except Exception: log.exception( "proactive: tick failed session=%s user=%s", session_id, user_id, ) async def _loop(self) -> None: log.info("proactive: driver started (poll=%.1fs)", self._poll_seconds) while self._running: try: await self._tick_once() except Exception: log.exception("proactive: tick loop iteration crashed; continuing") await asyncio.sleep(self._poll_seconds) log.info("proactive: driver stopped") def start(self) -> None: if self._running: return self._running = True self._task = asyncio.get_running_loop().create_task(self._loop()) async def stop(self) -> None: self._running = False if self._task is not None: try: await asyncio.wait_for(self._task, timeout=self._poll_seconds + 2) except (asyncio.TimeoutError, asyncio.CancelledError): pass self._task = None
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.
3b. Build a ProactiveTriggerContext from AsyncContextStore
Add these helpers near the bottom of the file, before the FastAPI route handlers.
copilot_server.py — proactive context helpers (expand to copy)
def _get_proactive_sessions(): """Yield (user_id, latest_session_id) for every tracked user.""" for user_id, sessions in _user_sessions.items(): if not sessions: continue latest_session_id, _ts = sorted(sessions, key=lambda x: -x[1])[0] yield user_id, latest_session_iddef _get_proactive_context( user_id: str, session_id: str,) -> ProactiveTriggerContext | None: """Build a ProactiveTriggerContext from the SDK's AsyncContextStore.""" product_id = _session_product.get(session_id, "") key = actions_bucket_id(product_id, session_id) payloads = context_store._actions.get(key) # noqa: SLF001 if not payloads: return None slim_actions = tuple(a for p in payloads for a in p.actions) if not slim_actions: return None return ProactiveTriggerContext.from_slim_actions( slim_actions, session_id=session_id, product_id=product_id or "demo", action_count=len(slim_actions), )
Reaching into context_store._actions is the only way to get raw SlimAction instances back out for trigger evaluation — the public get() method returns formatted prompt text. A public AsyncContextStore.get_actions(session_id, product_id) helper has been raised with the SDK team as upstream feedback.
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)
_PROACTIVE_MESSAGES: dict[str, str] = { "bulk_edit_opportunity": ( "Updating projects one by one? There's a multi-select bulk edit " "you might not have seen — want me to show you?" ),}# Authoritative "how-to" text per trigger. When the user clicks Open chat,# this is fed to the LLM as ground truth so the follow-up reply has real# UI steps instead of plausibly-hallucinated ones._TRIGGER_HOW_TO: dict[str, str] = { "bulk_edit_opportunity": ( "How bulk edit works on the Projects page:\n" "1. At the top right of the table (next to the status filter), " "click the small kebab menu icon (the three dots: ⋯).\n" "2. A dropdown opens — pick 'Bulk edit'. Checkboxes appear on every " "row.\n" "3. Tick the projects you want to update.\n" "4. A small toolbar appears at the bottom of the screen. Pick the " "new priority from the dropdown.\n" "5. Click 'Apply to N' — all selected projects update in one shot." ),}
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
copilot_server.py — _deliver_to_app (expand to copy)
async def _deliver_to_app( session_id: str, user_id: str, result: ProactiveTriggerResult,) -> None: """Push a proactive message straight to the host app via SSE. Rasa is intentionally NOT in this path — proactive UX is owned by the app shell, not the chat vendor.""" current_page = _current_page_hint(user_id) # defined in Step 8 event = { "trigger_id": result.trigger_id, "body": _PROACTIVE_MESSAGES.get(result.trigger_id, result.body), "session_id": session_id, "tours": _tour_payload_for_trigger(result.trigger_id, current_page), # Step 7 } delivered = _publish_proactive(user_id, event) log.info( "proactive: published session=%s user=%s trigger=%s subscribers=%d", session_id, user_id, result.trigger_id, delivered, )
<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.Update app/posthog-provider.js:
app/posthog-provider.js — full file (expand to copy)
"use client";import { useEffect } from "react";import { usePathname, useSearchParams } from "next/navigation";import posthog from "posthog-js";export default function PostHogProvider({ children }) { const pathname = usePathname(); const searchParams = useSearchParams(); useEffect(() => { if (typeof window === "undefined" || posthog.__loaded) return; posthog.init("phc_YOUR_PROJECT_API_KEY", { api_host: "https://us.i.posthog.com", person_profiles: "identified_only", session_idle_timeout_seconds: 120, // SPA pageviews are captured manually below — PostHog's // `capture_pageview: true` only catches the initial load. capture_pageview: false, capture_pageleave: true, // For the demo: ship events immediately so the proactive pipeline // isn't sitting on a 30s client-side batch. Production apps keep // batching on. request_batching: false, loaded: (ph) => { ph.identify("USER_ID_FROM_YOUR_AUTH", { product_id: "YOUR_AUTOPLAY_PRODUCT_ID", email: "user@theirdomain.com", }); ph.register({ email: "user@theirdomain.com" }); }, }); }, []); // Fire $pageview on every route change so canonical_url reflects where // the user actually is — not where they last clicked. useEffect(() => { if (typeof window === "undefined") return; const qs = searchParams?.toString(); const url = qs ? `${pathname}?${qs}` : pathname; posthog.capture("$pageview", { $current_url: window.location.origin + url, }); }, [pathname, searchParams]); return children;}
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.
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".
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.
app/proactive-toast.js — toast component (expand to copy)
"use client";import { useEffect, useState, useCallback } from "react";const BRIDGE_URL = process.env.NEXT_PUBLIC_BRIDGE_URL || "http://localhost:8090";// IMPORTANT: this MUST be the same value you pass to `posthog.identify(...)`// in `app/posthog-provider.js` AND to `customData.userId` in// `app/rasa-widget.js`. The bridge keys the SSE stream, the proactive FSM,// and the chat session by this id — if they don't match, the toast never// reaches the right tab and the LLM follow-up can't read the user's activity.const USER_ID = "demo-user-1";const AUTO_DISMISS_MS = 20_000;function openChatWidget() { const launcher = document.querySelector(".rw-launcher") || document.querySelector("[class*=launcher]"); if (launcher instanceof HTMLElement) launcher.click();}// injectIntoChat + startTour are added in Steps 6 and 7 (top of this file).export default function ProactiveToast() { const [toasts, setToasts] = useState([]); const dismiss = useCallback((id) => { setToasts((prev) => prev.filter((t) => t.id !== id)); }, []); useEffect(() => { if (typeof window === "undefined") return; const source = new EventSource( `${BRIDGE_URL}/proactive/stream/${encodeURIComponent(USER_ID)}` ); source.addEventListener("proactive", (ev) => { try { const data = JSON.parse(ev.data); const id = `${data.trigger_id}-${Date.now()}`; setToasts((prev) => [ ...prev, { id, triggerId: data.trigger_id, body: data.body, tours: Array.isArray(data.tours) ? data.tours : [], }, ]); setTimeout(() => dismiss(id), AUTO_DISMISS_MS); } catch { // malformed event — ignore } }); return () => source.close(); }, [dismiss]); if (toasts.length === 0) return null; return ( <div className="pointer-events-none fixed bottom-24 right-6 z-50 flex flex-col gap-3"> {toasts.map((t) => ( <div key={t.id} className="pointer-events-auto w-80 rounded-xl border border-zinc-200 bg-white p-4 text-sm shadow-xl dark:border-zinc-700 dark:bg-zinc-900" > <div className="flex items-start gap-3"> <div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-indigo-600 text-white"> 💬 </div> <div className="flex-1"> <div className="text-xs font-medium uppercase tracking-wide text-zinc-500"> Copilot </div> <div className="mt-1 leading-snug">{t.body}</div> </div> <button type="button" onClick={() => dismiss(t.id)} className="-mr-1 -mt-1 rounded p-1 text-zinc-400 hover:bg-zinc-100" > × </button> </div> <div className="mt-3 flex flex-wrap gap-2"> {t.tours.map((tour) => ( <button key={tour.id} type="button" onClick={() => { startTour(tour); dismiss(t.id); }} className="rounded-md bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-700" > {tour.label || "Show me"} </button> ))} <button type="button" onClick={() => { injectIntoChat(t.body, t.triggerId); dismiss(t.id); }} className="rounded-md border border-zinc-300 px-3 py-1.5 text-xs font-medium text-zinc-700 hover:bg-zinc-100" > Open chat </button> </div> </div> ))} </div> );}
Mount it once in app/layout.js (next to your existing providers):
import ProactiveToast from "./proactive-toast";// ...<ProactiveToast />
💬 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_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.
Add ActionSendProactive to rasa-bot/actions/actions.py:
class ActionSendProactive(Action): """Utter the proactive body the bridge supplied via trigger_intent.""" def name(self) -> str: return "action_send_proactive" async def run(self, dispatcher, tracker, domain): text = tracker.get_slot("proactive_text") or "" if text: dispatcher.utter_message(text=text) return []
Retrain Rasa and restart:
docker compose run --rm rasa traindocker compose restart rasa action-server
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.
copilot_server.py — _push_bot_message + _llm_reply (expand to copy)
async def _push_bot_message(sender_id: str, text: str) -> None: """Inject a single bot utterance into the widget's open SocketIO session.""" url = ( f"{RASA_URL}/conversations/{sender_id}/trigger_intent" "?output_channel=socketio" ) body = { "name": "EXTERNAL_proactive", "entities": {"proactive_text": text}, } async with httpx.AsyncClient(timeout=10.0) as client: r = await client.post(url, json=body) r.raise_for_status()async def _llm_reply(user_id: str, query: str) -> str: """Same prompt-assembly + OpenAI call /reply uses, reusable so the proactive follow-up can synthesise a contextual answer without a second HTTP hop.""" assembly = _build_assembly(user_id, query) user_prompt = build_user_prompt_block(assembly) name = _name_from_email(_user_emails.get(user_id)) sys_prompt = SYSTEM_PROMPT + ( f"\n\nUser's first name: {name}." if name else "" ) resp = await _openai.chat.completions.create( model=LLM_MODEL, temperature=0.4, max_tokens=250, messages=[ {"role": "system", "content": sys_prompt}, {"role": "user", "content": user_prompt}, ], ) return (resp.choices[0].message.content or "").strip()
copilot_server.py — /proactive/inject endpoint (expand to copy)
@app.post("/proactive/inject")async def proactive_inject(payload: dict) -> dict[str, Any]: """User clicked "Open chat" — push the question, then auto-followup with an LLM answer grounded in _TRIGGER_HOW_TO. Frontend passes: - sender_id → rasa-webchat's socket session id (from sessionStorage) - body → the proactive message body (what the toast showed) - user_id → the auth user id (for the LLM activity context) - trigger_id → which trigger fired (for the how-to lookup) """ sender_id = str(payload.get("sender_id") or "") body = str(payload.get("body") or "") user_id = str(payload.get("user_id") or "") trigger_id = str(payload.get("trigger_id") or "") if not sender_id or not body: raise HTTPException(400, "sender_id and body required") # 1) Send the proactive question. try: await _push_bot_message(sender_id, body) log.info("proactive: injected question sender=%s len=%d", sender_id, len(body)) except Exception as exc: log.warning("proactive: question push failed: %s", exc) raise HTTPException(502, f"rasa trigger_intent failed: {exc}") # 2) Auto-follow-up with the LLM answer — the user clicked Open chat, # that IS the "yes." Bake the proactive question AND the # authoritative how-to text into the synthesised user message so # the LLM has ground truth for the UI steps. how_to = _TRIGGER_HOW_TO.get(trigger_id, "") if user_id: try: synth = ( f'Yes, please walk me through it. You just offered: ' f'"{body}".\n\n' ) if how_to: synth += ( "The exact steps for this feature in this app are:\n" f"{how_to}\n\n" "Rewrite those steps in a friendly tone, referencing " "what I've been doing on the page. Use the activity log " "to make it feel personal — but do NOT invent any " "button names or UI elements that are not in the steps " "above. Stay accurate to the steps." ) else: synth += ( "Give me concrete, step-by-step instructions for the " "feature you mentioned. Reference what I have been " "doing on the page. Do NOT ask me to clarify." ) followup = await _llm_reply(user_id, synth) if followup: # Small gap so the two bubbles don't render simultaneously. await asyncio.sleep(0.6) await _push_bot_message(sender_id, followup) log.info("proactive: injected followup sender=%s len=%d", sender_id, len(followup)) except Exception as exc: log.warning("proactive: followup failed: %s", exc) # Non-fatal — at least the question landed. return {"status": "ok"}
function getRasaSessionId() { try { const raw = sessionStorage.getItem("chat_session"); return raw ? JSON.parse(raw)?.session_id || null : null; } catch { return null; }}async function injectIntoChat(body, triggerId) { const senderId = getRasaSessionId(); if (!senderId) { openChatWidget(); return; } // Open the widget first so the bot bubbles land in a visible chat. openChatWidget(); try { await fetch(`${BRIDGE_URL}/proactive/inject`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sender_id: senderId, body, user_id: USER_ID, trigger_id: triggerId, }), }); } catch { // non-fatal — chat is already open, user can ask manually }}
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:
app/rasa-widget.js — updated widget (expand to copy)
"use client";import { useEffect } from "react";export default function RasaWidget() { useEffect(() => { if (typeof window === "undefined") return; // Start every page load with a fresh chat. Run BEFORE the script-exists // guard so soft refreshes also clear it. sessionStorage.removeItem("chat_session"); if (document.getElementById("rasa-webchat-script")) return; const script = document.createElement("script"); script.id = "rasa-webchat-script"; script.src = "https://cdn.jsdelivr.net/npm/rasa-webchat@1.0.1/lib/index.js"; script.async = true; script.onload = () => { window.WebChat.default( { customData: { language: "en", userId: "demo-user-1" }, socketUrl: "http://localhost:5005", title: "Demo Copilot", subtitle: "Powered by Autoplay + Rasa", inputTextFieldHint: "Ask me what you just did, or for help...", showFullScreenButton: false, params: { storage: "session" }, }, null, ); }; document.body.appendChild(script); }, []); return null;}
In Step 1’s /reply endpoint, add a single call that flips the FSM into REACTIVE for the user’s session whenever they send a chat message. This prevents new proactive triggers from interrupting mid-conversation.
copilot_server.py — _latest_session_for_user + _mark_reactive (expand to copy)
def _latest_session_for_user(user_id: str) -> str | None: """Most-recent session for this user — used by _mark_reactive (below) and by the tour-lifecycle endpoints in Step 7.""" sessions = _user_sessions.get(user_id) or [] if not sessions: return None session_id, _ts = sorted(sessions, key=lambda x: -x[1])[0] return session_iddef _mark_reactive(user_id: str) -> None: """Tell the FSM the user just sent a chat message. Two SDK calls per the agent-state-v2 contract: - `transition_to_reactive()` moves THINKING → REACTIVE so future proactive triggers are gated until the conversation ends. - `record_user_interaction()` resets `interaction_timeout_s` so the FSM doesn't drift back to THINKING mid-conversation. """ session_id = _latest_session_for_user(user_id) if not session_id: return state = _session_state(session_id) try: if state.current_state == AgentStateV2.THINKING: state.transition_to_reactive() except Exception as exc: log.debug("FSM transition_to_reactive skipped: %s", exc) try: state.record_user_interaction() except Exception as exc: log.debug("FSM record_user_interaction skipped: %s", exc)@app.get("/reply/{user_id}")async def get_reply(user_id: str, query: str) -> dict[str, Any]: if not query: raise HTTPException(status_code=400, detail="query required") _mark_reactive(user_id) # ← new # ... rest of the Step 1 implementation ...
The Rasa session ID lives in sessionStorage, keyed by chat_session → session_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.
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.
Settings → Environments → copy the Usertour.js token (public, frontend-safe).
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.
Build 4 steps on the /projects page targeting these selectors:
#
Step name
CSS selector
Tooltip text
Advance trigger
1
Open table options
#projects-table-options
Click the ⋯ icon to open the table-options menu — bulk edit hides here.
Click on element → step 2
2
Pick Bulk edit
#projects-bulk-edit-toggle
Pick “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-apply
Pick 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.
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.
copilot_server.py — TourRegistry + tour-payload helper (expand to copy)
TOUR_REGISTRY = TourRegistry.from_dict({ "product_id": "YOUR_AUTOPLAY_PRODUCT_ID", "default_interaction_timeout_s": 60.0, "default_cooldown_period_s": 120.0, "tours": [ { "id": "projects-bulk-edit", "user_tour_id": "YOUR_USERTOUR_FLOW_ID", # from Step 7a step 7 "user_tour_name": "Projects bulk edit", }, ],})# Which tours are offered as a "Show me" CTA when each trigger fires._TRIGGER_TOURS: dict[str, list[str]] = { "bulk_edit_opportunity": ["projects-bulk-edit"],}# A tour only runs on pages where its anchors exist. Substring match against# the user's current canonical URL._TOUR_PAGE_PATTERNS: dict[str, str] = { "projects-bulk-edit": "/projects",}def _tour_payload_for_trigger( trigger_id: str, current_page: str | None,) -> list[dict]: """Return [{id, flow_id, label}] for tours to surface on this trigger. Tours are filtered by `current_page` — a tour's anchors only exist on its target page, so offering "Show me" anywhere else spotlights nothing.""" if not current_page: return [] out: list[dict] = [] for tour_id in _TRIGGER_TOURS.get(trigger_id, []): page_pattern = _TOUR_PAGE_PATTERNS.get(tour_id) if page_pattern and page_pattern not in current_page: continue tour_def = TOUR_REGISTRY.get(tour_id) if tour_def is None or not tour_def.user_tour_id: continue out.append({ "id": tour_def.id, "flow_id": tour_def.user_tour_id, "label": "Show me", }) return out
copilot_server.py — /tour/start, /tour/step, /tour/end (expand to copy)
@app.post("/tour/start")async def tour_start(payload: dict) -> dict[str, Any]: """User accepted the "Show me" CTA; return the Usertour flow_id and transition the FSM into visual-guidance mode.""" user_id = str(payload.get("user_id") or "") tour_id = str(payload.get("tour_id") or "") if not user_id or not tour_id: raise HTTPException(400, "user_id and tour_id required") tour_def = TOUR_REGISTRY.get(tour_id) if tour_def is None or not tour_def.user_tour_id: raise HTTPException(404, f"unknown tour {tour_id}") session_id = _latest_session_for_user(user_id) if session_id: state = _session_state(session_id) try: state.set_visual_guidance(active=True, tour_id=tour_id) log.info("tour: started user=%s tour=%s flow=%s session=%s", user_id, tour_id, tour_def.user_tour_id, session_id) except Exception as exc: log.warning("tour: set_visual_guidance failed: %s", exc) else: log.warning("tour: no session for user=%s", user_id) return {"status": "ok", "flow_id": tour_def.user_tour_id, "tour_id": tour_id}@app.post("/tour/step")async def tour_step(payload: dict) -> dict[str, str]: """Frontend pings here on each tour step so the FSM's interaction timer keeps resetting and proactive triggers stay gated.""" user_id = str(payload.get("user_id") or "") session_id = _latest_session_for_user(user_id) if session_id is None: return {"status": "no_session"} state = _session_state(session_id) try: state.record_tour_step() except Exception as exc: log.warning("tour: record_tour_step failed: %s", exc) return {"status": "ok"}@app.post("/tour/end")async def tour_end(payload: dict) -> dict[str, str]: """Tour completed or dismissed — clear the visual-guidance flag.""" user_id = str(payload.get("user_id") or "") session_id = _latest_session_for_user(user_id) if session_id is None: return {"status": "no_session"} state = _session_state(session_id) try: state.set_visual_guidance(active=False) log.info("tour: ended user=%s session=%s", user_id, session_id) except Exception as exc: log.warning("tour: set_visual_guidance(end) failed: %s", exc) return {"status": "ok"}
app/proactive-toast.js — startTour helper (expand to copy)
// Best-effort: try the public methods Usertour exposes for programmatic start.// `usertour.js` has shipped multiple shapes — pin the version you tested OR// keep this fallthrough so customers on older / newer minor versions still work.function startUsertourFlow(flowId) { const u = window.usertour; if (!u) return false; if (typeof u.start === "function") { u.start(flowId); return true; } if (u.flow && typeof u.flow.start === "function") { u.flow.start(flowId); return true; } if (u.flows && typeof u.flows.start === "function") { u.flows.start(flowId); return true; } return false;}async function startTour(tour) { // Flip the FSM into visual-guidance mode + log on the bridge. try { await fetch(`${BRIDGE_URL}/tour/start`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ user_id: USER_ID, tour_id: tour.id }), }); } catch { // non-fatal — still run the tour } const launched = startUsertourFlow(tour.flow_id); if (!launched) { console.warn( "[proactive] Usertour SDK not ready; could not start flow", tour.flow_id, ); }}
🧭 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)
def _current_page_hint(user_id: str) -> str | None: """Latest URL the user is on — picked by `timestamp_start`, not array order. PostHog batches don't always arrive in chronological order, so the last element in a payload is not necessarily the most recent action. We scan all actions across all payloads and pick the one with the largest `timestamp_start`. """ sessions = _user_sessions.get(user_id) or [] latest_action = None latest_ts = float("-inf") for session_id, _ts in sorted(sessions, key=lambda x: -x[1]): product_id = _session_product.get(session_id) key = actions_bucket_id(product_id, session_id) bucket = context_store._actions.get(key) # noqa: SLF001 if not bucket: continue for payload in bucket: for action in payload.actions: if not action.canonical_url: continue try: ts = ( float(action.timestamp_start) if action.timestamp_start else 0.0 ) except (TypeError, ValueError): ts = 0.0 if ts > latest_ts: latest_ts = ts latest_action = action if latest_action is not None: break # most-recent session has actions; don't fall through return latest_action.canonical_url if latest_action else None
reversed(actions) is not the same as “most recent.” PostHog batches don’t always arrive in chronological order — the last element in a payload can be older than items earlier in the same batch. Always pick by timestamp_start if you need the actual latest action.
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.
In the bridge log you should see, in order:
INFO copilot.proactive: proactive: firing session=… user=demo-user-1 trigger=bulk_edit_opportunityINFO copilot: proactive: published session=… user=demo-user-1 trigger=bulk_edit_opportunity subscribers=1INFO copilot: tour: started user=demo-user-1 tour=projects-bulk-edit flow=… ← if user clicked "Show me"INFO copilot: proactive: injected question sender=… ← if user clicked "Open chat"INFO copilot: proactive: injected followup sender=… ← LLM auto-followup landed
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.
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, 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.
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).
# Terminal 1 — your web app# Terminal 2 — bridgecd ~/your-copilot/bridge && uv run uvicorn copilot_server:app --port 8090# Terminal 3 — Rasa stackcd ~/your-copilot/rasa-bot && docker compose up -d
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.
You now have a Rasa chatbot 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 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.