Skip to main content
This tutorial gets you from zero to a working Rasa bot with grounded replies in about 45 minutes.

End-to-end walkthrough (watch first)


What you’ll build:
  1. Frontend autocapture with posthog.identify() + posthog.register({email}).
  2. Product onboarding with onboard_product for ingest + MCP read credentials.
  3. PostHog destination forwarding events to your Autoplay connector.
  4. A small FastAPI bridge that pulls a user’s recent activity on demand over REST (httpx GET -> ChatContextAssembly -> SessionState) — no stream to subscribe to.
  5. Rasa + action server in Docker, plus a widget connected over WebSocket.
  6. An end-to-end check where responses are grounded in recent user behavior.
Runtime loop: click in app -> event ingested by the Autoplay connector -> user sends message -> Rasa action calls bridge -> bridge pulls the user’s recent activity over REST -> bridge assembles prompt -> LLM returns grounded answer.

🪝 Step 1 — Capture clicks in your web app with PostHog

Install posthog-js and initialize it once on app load.
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.

📝 Step 2 — Register your product with Autoplay

Run a one-time script to create your ingest + MCP read credentials.
Create bridge/register_product.py:
Run it once:
It prints six values — save them. We’ll use all six:
  • product_id — the id you registered.
  • providerposthog.
  • ingest_url — PostHog will POST events here (e.g. https://connector.autoplay.ai/ingest/YOUR_AUTOPLAY_PRODUCT_ID).
  • ingest_secretX-PostHog-Secret header value for the destination.
  • mcp_url — always https://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.
  1. PostHog UI → Data pipeline → Destinations → + New destination → HTTP Webhook.
  2. Enable destination = ON.
  3. Webhook URL: paste ingest_url from Step 2.
  4. Method: POST. JSON Body: clear it. X-PostHog-Secret header: paste ingest_secret from Step 2 — do not create a new secret. Other headers: remove the default Content-Type row (the Hog code below sets headers itself).
  5. Click Edit source and paste this script.
  1. Click Test function — expect status 200 in under 200 ms.
  2. Create & enable.
PostHog requires the Webhook URL field on the form even though the Hog source above overrides it. Paste the same ingest_url from Step 2 into both places.
Verify: click around your app, then check destination Logs for successful POSTs.

🧰 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:
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 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_keyMCP_KEY
product_idPRODUCT_ID
ingest_url(used in Step 3 — PostHog destination URL)
ingest_secret(used in Step 3 — PostHog destination header)
No 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

Create bridge/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

2b. Fetch a user’s recent activity

This is the entire “ingestion” surface now — one httpx 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.
Why raw and formatted variants. /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 a session_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.
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

Simplification vs. the old build. Previously 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.
  1. Acknowledge the user’s activity naturally — don’t recite a click log.
  2. 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.
No FastAPI 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

You should see uvicorn’s normal startup log — no “connected” line to wait for, since there’s no connection to establish:
Smoke-test:
Click around in your app for ~30 seconds, then re-check:
That confirms events are flowing into the connector and your reply path is grounded. (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.
Create rasa-bot/docker-compose.yml:
Create rasa-bot/endpoints.docker.yml:
Create rasa-bot/credentials.yml:
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.
Create rasa-bot/config.yml (NLU + policies):
Create rasa-bot/domain.yml:
Now the three training files under rasa-bot/data/. Create rasa-bot/data/nlu.yml:
Create rasa-bot/data/rules.yml:
Create rasa-bot/data/stories.yml:
The critical bit is the last rule in rules.ymlnlu_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

Create rasa-bot/actions/__init__.py (empty), rasa-bot/actions/Dockerfile, and rasa-bot/actions/actions.py:
rasa/rasa-sdk:3.6.2 doesn’t include httpx. Without this Dockerfile, the action-server container starts but immediately fails to register the actions package with ModuleNotFoundError: No module named 'httpx'curl http://localhost:5055/health returns connection refused even though docker compose ps shows the container “Up”. The Dockerfile pip-installs httpx so actions.py can import it.
What it does per chat turn:
  1. Read customData.userId (and, optionally, customData.email) from the widget.
  2. HTTP GET the bridge /reply/{user_id}?query=….
  3. Return whatever the bridge gave us.
No SDK imports, no LLM keys, no context tracking: Rasa stays thin.

🚀 Step 8 — Train, start, smoke test

Verify:
End-to-end test through Rasa’s REST channel (no widget yet):
Expected: a conversational reply that references real recent actions. If you see “I haven’t seen activity,” generate a few clicks first.

💬 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.
Mount it once in your root layout with the same userId you passed to posthog.identify(). The chat bubble appears bottom-right.

✅ Step 10 — Try it

  1. 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.
  2. Open the chat bubble.
  3. 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.
The bot should not recite click logs. Activity is a private signal used to improve answer relevance. In Step 2 we’ll go further: the bot will notice when you’re editing projects one-by-one and offer to show you the (hidden) bulk-edit feature before you finish — without you typing anything. If answers feel generic, check bridge logs and the troubleshooting matrix.

🛠 Troubleshooting

SymptomLikely 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 / similarCONNECTOR_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 themPRODUCT_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 nameNo 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 logsmetadata_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 RasaUsing endpoints.yml (localhost) not endpoints.docker.yml (action-server hostname)
Bridge /reply returns activity but the bot doesn’tAction-server hasn’t picked up new actions.pydocker compose restart action-server
PostHog destination test returns url: This field is requiredPaste the same ingest_url into the form-level URL field too
API key is not valid: personal_api_keyUse phc_… (Project) key, not phx_… (Personal)

🔄 Day-2 operations

After editing actions/actions.py:
After editing any Rasa .yml (domain, nlu, stories, rules, config):
After editing the bridge: re-run 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.
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.