End-to-end walkthrough (watch first)
What you’ll build:
- Frontend autocapture with
posthog.identify()+posthog.register({email}). - Product onboarding with
onboard_productfor ingest + MCP read credentials. - PostHog destination forwarding events to your Autoplay connector.
- A small FastAPI bridge that pulls a user’s recent activity on demand over REST (
httpxGET ->ChatContextAssembly->SessionState) — no stream to subscribe to. - Rasa + action server in Docker, plus a widget connected over WebSocket.
- An end-to-end check where responses are grounded in recent user behavior.
🪝 Step 1 — Capture clicks in your web app with PostHog
Installposthog-js and initialize it once on app load.
app/layout.js in Next.js). Autocapture then sends clicks, page views, and form submits automatically.
Verify: open the app, click around, then check PostHog → Activity for $autocapture events on your user.
📝 Step 2 — Register your product with Autoplay
Run a one-time script to create your ingest + MCP read credentials.bridge/register_product.py:
product_id— the id you registered.provider—posthog.ingest_url— PostHog will POST events here (e.g.https://connector.autoplay.ai/ingest/YOUR_AUTOPLAY_PRODUCT_ID).ingest_secret—X-PostHog-Secretheader value for the destination.mcp_url— alwayshttps://mcp.autoplay.ai/mcp(informational — Rasa doesn’t speak MCP, we read the equivalent REST endpoint directly).mcp_key— Bearer token for the REST live-activity read the bridge does in Step 5.
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 until you pass force=True — after a successful overwrite, ingest_secret rotates, so update the PostHog destination (Step 3) to match.🔗 Step 3 — Wire PostHog → Autoplay via a HogQL destination
Configure a PostHog destination to forward each autocapture event to your Autoplay webhook.- PostHog UI → Data pipeline → Destinations → + New destination → HTTP Webhook.
- Enable destination = ON.
- Webhook URL: paste
ingest_urlfrom Step 2. - Method: POST. JSON Body: clear it.
X-PostHog-Secretheader: pasteingest_secretfrom Step 2 — do not create a new secret. Other headers: remove the defaultContent-Typerow (the Hog code below sets headers itself). - Click Edit source and paste this script.
HogQL destination source (expand to copy)
HogQL destination source (expand to copy)
- Click Test function — expect status 200 in under 200 ms.
- Create & enable.
🧰 Step 4 — Scaffold the bridge project
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:
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.bridge/.env with three of the six credentials returned by onboard_product plus your OpenAI key. Map them as follows:
onboard_product field | .env variable |
|---|---|
mcp_url (origin only, no /mcp path) | CONNECTOR_URL |
mcp_key | MCP_KEY |
product_id | PRODUCT_ID |
ingest_url | (used in Step 3 — PostHog destination URL) |
ingest_secret | (used in Step 3 — PostHog destination header) |
STREAM_URL or UNKEY_API_KEY anymore. The connector is pull-based — the bridge asks for activity when it needs it instead of holding a connection open, so there’s no stream endpoint or stream-auth token to configure. MAX_ACTIONS now maps straight onto the REST limit query param. There is no SUMMARY_THRESHOLD or LOOKBACK_SECONDS to set — the live-activity endpoint already returns a bounded, recent window of actions, so the bridge passes them straight to the LLM instead of running a local rolling summary.🔌 Step 5 — Wire the REST-pull pipeline
Createbridge/copilot_server.py. Most of the code is configuration plus one /reply/{user_id} endpoint. There’s no connection to open, no callback to register, and no background task to manage — the bridge just calls the connector’s REST endpoint the moment it needs a user’s activity.
2a. Imports and config
copilot_server.py imports and config (expand to copy)
copilot_server.py imports and config (expand to copy)
2b. Fetch a user’s recent activity
This is the entire “ingestion” surface now — onehttpx GET, keyed by product_id + user_id. No session_id, no local buffering, no summarisation: the endpoint already returns a bounded, recent window of actions, so we hand them to the LLM as-is.
copilot_server.py — fetch_recent_activity_raw / fetch_recent_activity_text (expand to copy)
copilot_server.py — fetch_recent_activity_raw / fetch_recent_activity_text (expand to copy)
/reply only needs the formatted text block for the prompt. Step 2’s proactive driver needs the raw actions list — it evaluates a predicate over fields like timestamp_start that the formatted text throws away. Keep both.2c. Track per-user FSM state
The old build kept asession_id -> user_id index because AsyncContextStore only knew sessions. The REST read is already scoped to user_id directly, so that index — and the _session_product / _user_sessions bookkeeping it required — is gone. All that’s left to track locally is the proactive FSM state (used starting in Step 2) and, optionally, a cached email for name personalization.
copilot_server.py — per-user state (expand to copy)
copilot_server.py — per-user state (expand to copy)
SessionState is unused until Step 2. It’s introduced here because Step 2 keys it by the same user_id this file already has in scope — nothing in Step 1 calls _user_state() yet. session_id is SessionState’s one required field with no default; since this build has no session concept, we pass the user_id straight through as the FSM’s scope key.2d. Helper — name from email
copilot_server.py — _name_from_email (expand to copy)
copilot_server.py — _name_from_email (expand to copy)
email rode along on every SSE push (payload.email), so the bridge learned it for free. The pull API has no equivalent push, so there’s nothing to key _user_emails from automatically anymore. This tutorial has /reply accept an optional email query param that the action server forwards when the widget’s customData has one (Step 7) — first call after login populates _user_emails, later calls keep it fresh. If sourcing email this way doesn’t fit your stack, it’s safe to drop _user_emails / _name_from_email / the first-name greeting entirely — it’s cosmetic, not part of the ingestion path.2e. 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.
copilot_server.py reply prompt and endpoint (expand to copy)
copilot_server.py reply prompt and endpoint (expand to copy)
lifespan in this build. The old version opened the SSE connection in a lifespan context manager so it could clean it up on shutdown. There’s nothing to open or close now — the app starts instantly and every /reply call makes its own short-lived REST request. Step 2 reintroduces a lifespan purely to start/stop the proactive polling loop, which is a different concern (a periodic background tick, not a connection).Start the bridge
name is null here because no email query param was sent yet — see the Note in 2d.)
🤖 Step 6 — Scaffold Rasa in Docker
Rasa 3.6 on Apple Silicon hits TensorFlow ABI issues; Docker sidesteps them.rasa-bot/docker-compose.yml:
rasa-bot/docker-compose.yml (expand to copy)
rasa-bot/docker-compose.yml (expand to copy)
rasa-bot/endpoints.docker.yml:
rasa-bot/credentials.yml:
rasa-bot/config.yml (NLU + policies):
rasa-bot/config.yml (expand to copy)
rasa-bot/config.yml (expand to copy)
rasa-bot/domain.yml:
rasa-bot/domain.yml (expand to copy)
rasa-bot/domain.yml (expand to copy)
rasa-bot/data/.
Create rasa-bot/data/nlu.yml:
rasa-bot/data/nlu.yml (expand to copy)
rasa-bot/data/nlu.yml (expand to copy)
rasa-bot/data/rules.yml:
rasa-bot/data/rules.yml (expand to copy)
rasa-bot/data/rules.yml (expand to copy)
rasa-bot/data/stories.yml:
rasa-bot/data/stories.yml (expand to copy)
rasa-bot/data/stories.yml (expand to copy)
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.
⚡ Step 7 — Rasa action server: a thin HTTP wrapper
Createrasa-bot/actions/__init__.py (empty), rasa-bot/actions/Dockerfile, and rasa-bot/actions/actions.py:
rasa-bot/actions/actions.py (expand to copy)
rasa-bot/actions/actions.py (expand to copy)
- Read
customData.userId(and, optionally,customData.email) from the widget. - HTTP GET the bridge
/reply/{user_id}?query=…. - Return whatever the bridge gave us.
🚀 Step 8 — Train, start, smoke test
💬 Step 9 — Drop the chat widget into your app
@rasahq/rasa-chat is locked to React 17 and breaks on Next 14 / React 18. The CDN build of rasa-webchat works in any framework.
userId you passed to posthog.identify(). The chat bubble appears bottom-right.
✅ Step 10 — Try it
- Open your app, click around for ~30 seconds — e.g. browse to
/projects, open the Edit dialog on a row, change a field, click Save changes. - Open the chat bubble.
- Ask one of these:
- “what did I just do?” → the bot recaps your last few clicks in your own words (“Looks like you’ve been updating projects — the most recent one was…”).
- “how do I change a project’s priority?” → if your activity log shows you’re already in the edit dialog, the bot picks up after the click (“You’re in the edit dialog now — use the priority dropdown and click ‘Save changes’ to apply”) instead of re-explaining the whole flow.
🛠 Troubleshooting
| Symptom | Likely cause |
|---|---|
| Bot says “no recent activity” | (1) Bridge not running, (2) PostHog destination disabled/misconfigured, (3) customData.userId doesn’t match the posthog.identify() ID, or (4) the live-activity fetch is failing silently — check the bridge logs for live-activity fetch … warnings from fetch_recent_activity_raw |
Bridge logs live-activity fetch 401 user=… | MCP_KEY is wrong, stale, or copied from a different product’s onboard_product output |
Bridge logs live-activity fetch failed user=… : ConnectTimeout / similar | CONNECTOR_URL is wrong, or the bridge host can’t reach mcp.autoplay.ai (firewall/proxy) |
curl .../live-activity returns actions but /reply doesn’t use them | PRODUCT_ID in bridge/.env doesn’t match the product_id you curled, or user_id in the path doesn’t match customData.userId |
| Bot ignores user’s name | No email query param reached /reply — this is optional personalization (see the Note in Step 5, section 2d); confirm the widget sets customData.email and the action server’s _user_email picks it up, or skip it |
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 ingest_url into the form-level URL field too |
API key is not valid: personal_api_key | Use phc_… (Project) key, not phx_… (Personal) |
🔄 Day-2 operations
actions/actions.py:
.yml (domain, nlu, stories, rules, config):
uvicorn (or use --reload during development).
What you’ve built
You now have a Rasa support AI agent with replies grounded in real user activity.- Reusable bridge: switch chat frameworks later without rewriting the fetch/assembly logic.
- Bring-your-own model: swap LLM providers with the same async callable contract.
- No connection to manage: the connector is pull-based, so the bridge starts instantly and fetches exactly what it needs, when it needs it — no reconnect logic, no backpressure, no local buffering.