🪝 Step 1 — Capture clicks in your web app with PostHog
Install posthog-js and initialize it once on app load.
npm install posthog-js
// app/posthog-provider.js (or wherever your client-side init lives)"use client";import { useEffect } from "react";import posthog from "posthog-js";export default function PostHogProvider({ children }) { 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, loaded: (ph) => { ph.identify("USER_ID_FROM_YOUR_AUTH", { product_id: "YOUR_AUTOPLAY_PRODUCT_ID", email: "user@theirdomain.com", }); // Critical: makes email flow on every autocapture event. // Without this, ActionsPayload.email arrives as None. ph.register({ email: "user@theirdomain.com" }); }, }); }, []); return children;}
Mount this provider once at the top of your app (app/layout.js in Next.js). Autocapture then sends clicks, page views, and form submits automatically.
Use your Project API Key (starts with phc_). The other keys PostHog surfaces (phx_…) are personal/admin keys and posthog.init() will reject them with a misleading personal_api_key error.
Verify: open the app, click around, then check PostHog → Activity for $autocapture events on your user.
It prints four values — save them. We’ll use all four:
webhook_url — PostHog will POST events here.
webhook_secret — X-PostHog-Secret header value for the destination.
stream_url — the SSE endpoint the bridge subscribes to.
unkey_key — Bearer token for SSE auth.
contact_email is required. It is stored on the connector product row so Autoplay can reach you. Re-registering the same product_id returns 409 unless you pass force_reregister=True (rotates the webhook secret).
Click Test function — expect status 200 in under 200 ms.
Create & enable.
PostHog requires the Webhook URL field on the form even though the Hog source above overrides it. Paste the same webhook_url from Step 2 into both places.
Verify: click around your app, then check destination Logs for successful POSTs.
The bridge is the only service that touches the Autoplay SDK. Keep it small so Rasa remains a thin transport layer.You already created ~/your-copilot/bridge/ in Step 2. Add the remaining dependencies:
Why two folders?bridge/ runs on your host with autoplay-sdk (pydantic v2). rasa-bot/ runs in Docker because Rasa 3.x pins pydantic v1 — the two cannot share a venv. The HTTP boundary keeps them cleanly separated.
Create bridge/.env with the four credentials returned by onboard_product plus your OpenAI key:
AsyncAgentContextWriter enforces the SDK’s ordering guarantee: the rolling summary is delivered to your destination before the raw actions it replaces are removed. Your agent never sees a blank context window during a swap.
AsyncContextStore keys actions by session_id. Chat copilots start from user_id (your auth ID, surfaced via the widget’s customData.userId). The bridge maintains an index between them.
copilot_server.py session indexing (expand to copy)
_user_sessions: dict[str, list[tuple[str, float]]] = {} # user_id -> [(session_id, ts), ...]_user_emails: dict[str, str] = {}_session_product: dict[str, str] = {} # session_id -> product_id_session_states: dict[str, SessionState] = {}def _index_session(user_id, session_id, email): if not user_id or not session_id: return now = time.time() bucket = _user_sessions.setdefault(user_id, []) bucket[:] = [(s, t) for (s, t) in bucket if s != session_id and now - t < LOOKBACK_SECONDS] bucket.append((session_id, now)) if email: _user_emails[user_id] = emaildef _session_state(session_id): if session_id not in _session_states: _session_states[session_id] = SessionState( interaction_timeout_s=60.0, cooldown_period_s=120.0, ) return _session_states[session_id]
Pass product_id when reading from ContextStore. Since v0.6.7, AsyncContextStore keys buckets by (product_id, session_id) when payloads carry a product_id — which they always do in practice. Calling context_store.get(session_id) without product_id= silently returns an empty string. Track payload.product_id per session on ingest and pass it on read.
copilot_server.py helper functions (expand to copy)
def _name_from_email(email): if not email or "@" not in email: return None local = email.split("@", 1)[0] parts = [p for p in local.replace(".", " ").replace("_", " ").split() if p] return " ".join(p.capitalize() for p in parts) if parts else Nonedef _user_recent_activity(user_id): sessions = _user_sessions.get(user_id) or [] chunks = [] for session_id, _ts in sorted(sessions, key=lambda x: -x[1])[:3]: product_id = _session_product.get(session_id) text = context_store.get(session_id, product_id=product_id) if text: chunks.append(text) return "\n\n".join(chunks).strip()
2h. The /reply endpoint — SDK-assembled prompt + LLM call
This system prompt is the part you’ll most likely customize later for your product.
Acknowledge the user’s activity naturally — don’t recite a click log.
Pick up from the user’s last action — don’t tell them to do something they just did.
It stays generic and safe to copy as-is.
copilot_server.py reply prompt and endpoint (expand to copy)
SYSTEM_PROMPT = """You are a friendly and helpful assistant for users of this product.Focus on helping people find their way in the UI, complete workflows, and understand features. Assume some users are seeing the product for the first time.## 💬 How to use the "Current User Activity" contextYou may receive a "Current User Activity" block alongside the user's question. It shows what THIS user has been doing on the platform in the last few minutes — which page they are on and what they clicked. The activity is scoped to their session, so it reflects only their actions, not anyone else's.When this context is present:1. **Acknowledge their activity naturally** — for example: "I can see you're currently on the Projects page" or "It looks like you've been exploring the Dashboard."2. **Use it to give specific directions** — instead of generic instructions, reference where they are: "From the page you're on, click the blue 'Add Project' button at the top right."3. **Detect if they might be lost** — if their actions show them clicking around without a clear pattern, gently offer help: "It looks like you might be looking for something specific. Can I help you find it?"4. **Don't force it** — if the user's question has nothing to do with their current activity, just answer the question normally. Don't mention their activity unless it's helpful. Never recite a click-by-click log.5. **Pick up from the user's last action — don't restart the flow.** Treat the most recent click as the user's current position. If they clicked a button that starts a flow, your reply should begin AFTER that click, not before it. Never tell the user to click a button the activity log already shows them clicking.6. **Refer to UI only with names you can see in the activity log.** Use the exact button/link text that appears in the activity log. For elements you don't see in the log (fields inside a dialog, secondary buttons), describe them by role rather than guessing a label — e.g. "the confirm button at the bottom of the dialog," "the email field." Do not invent labels.## ❓ How to answer questions- **Be specific**: reference actual button names, tab labels, and menu items.- **Use numbered steps**: when explaining how to do something, use a numbered list.- **Keep it simple**: avoid technical jargon. Explain as if the user has never used the platform before.- **Be encouraging**: make users feel comfortable.- **Offer next steps**: after answering, suggest what they might want to do next.- **Admit when you don't know**: if you don't have the answer, say so honestly.## 🌐 LanguageRespond in the same language the user writes in.## ✅ Examples**A. User is on the Dashboard (no recent CTA click), asks "How do I create a project?":** "I can see you're currently on the Dashboard. To create a new project: 1. Click on 'My Projects' in the left sidebar 2. Click the 'Add Project' button at the top right 3. Fill in the required details and click 'Create' Would you like me to explain what each field means?"**B. User just clicked "Add Project" (a dialog is open), asks "How do I create a project?":**❌ WRONG (re-tells the click they already made): "Click 'My Projects' in the sidebar, then click 'Add Project'…"✅ RIGHT (picks up after the click): "You're in the new-project dialog now — fill in the name and any required fields, then click the create button at the bottom of the dialog to finish."**C. User just clicked "Invite member" on the Team page, asks "how do I add a teammate?":**✅ "You're in the invite dialog — enter the teammate's email, pick a role from the dropdown, and click the send button at the bottom."(Notice: no step that says "click Invite member." The activity log showsthey already clicked it.)**D. User is on the Invoice page, asks "Where are settings?":** "The settings aren't on this page — click on your profile icon in the top right corner, then select 'Settings' from the dropdown."**E. User has no activity context, asks "What can I do here?":** "Welcome! Here's what you can do: 1. Dashboard — see an overview of your work 2. My Projects — create and manage projects 3. Reports — view analytics or exports 4. Billing — manage invoices or account settings What would you like to explore first?"Address the user by their first name once per conversation if known. Never invent actions the user did not actually take."""def _build_assembly(user_id, query): return ChatContextAssembly( recent_activity=_user_recent_activity(user_id), kb_records_text="", conversation_history_text="", user_message=query, )@app.get("/healthz")async def healthz(): return { "status": "ok", "users_tracked": len(_user_sessions), "sessions_tracked": sum(len(v) for v in _user_sessions.values()), }@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") 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 "") try: 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}, ], ) reply = (resp.choices[0].message.content or "").strip() except Exception as exc: log.exception("openai call failed") return {"reply": "Sorry — I hit a problem reaching the language model.", "error": str(exc)} return { "reply": reply, "name": name, "has_activity": bool(assembly.recent_activity.strip()), }
cd ~/your-copilot/bridgeuv run uvicorn copilot_server:app --host 0.0.0.0 --port 8090
You should see:
INFO copilot: autoplay stream listening on https://event-connector-luda.onrender.com/stream/YOUR_PRODUCT_IDINFO autoplay_sdk.async_client: autoplay_sdk: connected
Click around in your app for ~30 seconds, then re-check:
curl "http://localhost:8090/reply/YOUR_USER_ID?query=what+did+i+just+do"# {"reply":"You're already on Billing — just pick Pro and hit Confirm.", "name":"Muhammad", "has_activity":true}
That confirms events are flowing and your reply path is grounded.
The metadata_key: customData line is the join key between the chat widget’s customData.userId and Rasa’s tracker.latest_message.metadata. Without it, the action server can’t tell users apart — every chat reply will read random socket UUIDs as sender_id.
version: "3.1"intents: - greet - goodbye - bot_challenge - ask_what_just_happened - ask_help_current_page - nlu_fallbackresponses: utter_greet: - text: "Hey! I'm your copilot. I can see what you've been doing — ask me about it." utter_goodbye: - text: "Bye. Come back if you get stuck." utter_iamabot: - text: "I'm a bot powered by Rasa + Autoplay."actions: - action_recent_activity - action_help_current_page - action_ask_llm
Now the three training files under rasa-bot/data/.Create rasa-bot/data/nlu.yml:
rasa-bot/data/nlu.yml (expand to copy)
version: "3.1"nlu: - intent: greet examples: | - hi - hello - hey - good morning - good evening - hey there - intent: goodbye examples: | - bye - goodbye - see you - cya - thanks bye - intent: bot_challenge examples: | - are you a bot - are you human - what are you - who built you - intent: ask_what_just_happened examples: | - what did I just do - what was I doing - what just happened - recap my last actions - what have I been clicking - summarize my session - what did I just click - what was I working on - intent: ask_help_current_page examples: | - help me with this page - what can I do here - I'm stuck - help - what should I do next - guide me - I don't know what to do
The critical bit is the last rule in rules.yml — nlu_fallback → action_ask_llm. Rasa’s FallbackClassifier (configured in config.yml) emits nlu_fallback for any message that doesn’t match a known intent with high enough confidence. That rule routes those messages to the LLM-backed action, so the bot can answer free-form questions about your product.
Open your app, click around for ~30 seconds — e.g. start an invite flow but leave the role dropdown empty, or open the upgrade modal but don’t confirm.
Open the chat bubble.
Ask one of these:
“how do I add a teammate?” → if you’re already on the Team page with the invite modal open, the bot points at the next missing field rather than walking you through the whole flow.
The bot should not recite click logs. Activity is a private signal used to improve answer relevance.
(1) Bridge not running, (2) PostHog destination disabled, (3) customData.userId doesn’t match the posthog.identify() ID, or (4) context_store.get() was called without product_id — pass _session_product[session_id] on read
Bot ignores user’s name
payload.email = None. Check posthog.register({email}) is in your init, and the HogQL script forwards email
metadata={} in action server logs
metadata_key: customData missing from credentials.yml
Widget shows “Cannot reach server”
CORS — confirm rasa run has --cors '*' (the supplied compose file does)
localhost:5055/webhook connection refused from inside Rasa
Using endpoints.yml (localhost) not endpoints.docker.yml (action-server hostname)
Bridge /reply returns activity but the bot doesn’t
Action-server hasn’t picked up new actions.py — docker compose restart action-server
PostHog destination test returns url: This field is required
Paste the same webhook_url into the form-level URL field too
You now have a Rasa chatbot with replies grounded in real user activity.
Reusable bridge: switch chat frameworks later without rewriting SDK logic.
Bring-your-own model: swap LLM providers with the same async callable contract.
SDK-managed plumbing: reconnects, backpressure, summary ordering, and prompt assembly are handled for you.
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.