> ## Documentation Index
> Fetch the complete documentation index at: https://developers.autoplay.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Step 1 — Connect real-time events

> Pull a user's live actions on demand into a Rasa-aware bridge using the Autoplay SDK, expose them to Rasa over HTTP, and wire the chat widget.

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

### End-to-end walkthrough (watch first)

<div style={{ position: "relative", paddingBottom: "62.5%", height: 0 }}>
  <iframe src="https://www.loom.com/embed/d748e02164014d4cbcad568dbc4b3c05" title="Rasa tutorial — Step 1 walkthrough" frameBorder={0} loading="lazy" allow="clipboard-write; fullscreen" style={{ position: "absolute", top: 0, left: 0, width: "100%", height: "100%" }} />
</div>

***

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

```bash theme={null}
npm install posthog-js
```

```jsx theme={null}
// 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",
        });
        // Makes email flow on every autocapture event, so the HogQL
        // destination in Step 3 can forward it. Not required for the bridge
        // to work — see Step 5, section 2d, for how (and whether) to wire
        // up name personalization without a push channel.
        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.

<Warning>
  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.
</Warning>

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

```bash theme={null}
mkdir -p ~/your-copilot/bridge && cd ~/your-copilot/bridge
uv init --no-readme .
uv add 'autoplay-sdk==0.7.5'
```

Create `bridge/register_product.py`:

```python theme={null}
import asyncio
from autoplay_sdk.admin import onboard_product
from autoplay_sdk.providers import PostHogProvider

async def main() -> None:
    result = await onboard_product(
        "YOUR_AUTOPLAY_PRODUCT_ID",
        contact_email="you@yourcompany.com",
        user_activity_provider=PostHogProvider(),
        print_operator_summary=True,
    )
    print(result)

asyncio.run(main())
```

Run it once:

```bash theme={null}
uv run python register_product.py
```

It prints six values — save them. We'll use all six:

* `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-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.

<Note>
  **`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.
</Note>

***

## 🔗 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.

<AccordionGroup>
  <Accordion title="HogQL destination source (expand to copy)">
    ```js theme={null}
    fun extractFromElementsChain(str, pattern) {
        try {
            if (empty(str)) { return '' }
            let startIdx := position(str, pattern)
            if (startIdx <= 0) { return '' }
            let sub := substring(str, startIdx + length(pattern), length(str) - startIdx - length(pattern) + 1)
            let endIdx := position(sub, '"')
            if (endIdx > 0) { return substring(sub, 1, endIdx - 1) }
            return ''
        } catch (err) {
            return ''
        }
    }

    let elements_chain := event.elements_chain ?? ''
    let element_id := extractFromElementsChain(elements_chain, 'attr__id="')
    let input_field_name := extractFromElementsChain(elements_chain, 'attr__name="')
    let link_destination := extractFromElementsChain(elements_chain, 'attr__href="')
    let button_or_link_text := extractFromElementsChain(elements_chain, 'text="')

    let payload := {
        'event': event.event,
        'referrer': event.properties?.$referrer ?? '',
        'timestamp': event.timestamp ?? '',
        'element_id': element_id,
        'event_type': event.properties?.$event_type ?? '',
        'session_id': event.properties?.$session_id ?? '',
        'current_url': event.properties?.$current_url ?? '',
        'distinct_id': event.distinct_id ?? '',
        'email': event.properties?.email ?? '',
        'elements_chain': elements_chain,
        'input_field_name': input_field_name,
        'link_destination': link_destination,
        'button_or_link_text': button_or_link_text
    }

    let headers := {
        'Content-Type': 'application/json',
        'x-posthog-secret': inputs.headers['x-posthog-secret']
    }

    let req := { 'headers': headers, 'body': jsonStringify(payload), 'method': 'POST' }
    let url := inputs.url

    let res := fetch(url, req)
    if (res.status >= 400) {
        throw Error(f'Webhook returned {res.status}: {res.body}')
    }
    ```
  </Accordion>
</AccordionGroup>

6. Click **Test function** — expect status 200 in under 200 ms.
7. **Create & enable.**

<Warning>
  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.
</Warning>

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

```bash theme={null}
mkdir -p ~/your-copilot/rasa-bot
cd ~/your-copilot/bridge
uv add python-dotenv fastapi 'uvicorn[standard]' openai httpx
```

<Note>
  **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.
</Note>

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_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)* |

```ini theme={null}
CONNECTOR_URL=https://mcp.autoplay.ai
MCP_KEY=<mcp_key from onboard_product output>
PRODUCT_ID=YOUR_AUTOPLAY_PRODUCT_ID
OPENAI_API_KEY=sk-...
# Optional tuning:
LLM_MODEL=gpt-4o-mini
MAX_ACTIONS=30
```

<Note>
  **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.
</Note>

***

## 🔌 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

<AccordionGroup>
  <Accordion title="copilot_server.py imports and config (expand to copy)">
    ```python theme={null}
    import logging
    import os
    from typing import Any

    import httpx
    import openai
    from autoplay_sdk.agent_state.v2.states import SessionState
    from autoplay_sdk.rag.query.assembly import (
        ChatContextAssembly,
        build_user_prompt_block,
    )
    from dotenv import load_dotenv
    from fastapi import FastAPI, HTTPException

    load_dotenv()
    logging.basicConfig(level=logging.INFO)
    log = logging.getLogger("copilot")

    CONNECTOR_URL = os.environ.get("CONNECTOR_URL", "https://mcp.autoplay.ai")
    MCP_KEY = os.environ["MCP_KEY"]
    PRODUCT_ID = os.environ["PRODUCT_ID"]
    LLM_MODEL = os.environ.get("LLM_MODEL", "gpt-4o-mini")
    MAX_ACTIONS = int(os.environ.get("MAX_ACTIONS", "30"))

    _openai = openai.AsyncOpenAI(api_key=os.environ["OPENAI_API_KEY"])
    ```
  </Accordion>
</AccordionGroup>

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

<AccordionGroup>
  <Accordion title="copilot_server.py — fetch_recent_activity_raw / fetch_recent_activity_text (expand to copy)">
    ```python theme={null}
    async def fetch_recent_activity_raw(user_id: str) -> list[dict[str, Any]]:
        """GET the user's recent actions from the Autoplay connector.

        Returns the raw `actions` array (oldest -> newest) on success, or `[]`
        on any non-200 response or network error — from the caller's point of
        view, "no activity yet" and "couldn't reach the connector" degrade the
        same way: the LLM just answers without activity context.
        """
        try:
            async with httpx.AsyncClient(timeout=5.0) as client:
                r = await client.get(
                    f"{CONNECTOR_URL}/users/{PRODUCT_ID}/{user_id}/live-activity",
                    params={"limit": MAX_ACTIONS},
                    headers={"Authorization": f"Bearer {MCP_KEY}"},
                )
        except httpx.HTTPError as exc:
            log.warning("live-activity fetch failed user=%s: %s", user_id, exc)
            return []
        if r.status_code != 200:
            log.warning("live-activity fetch %s user=%s", r.status_code, user_id)
            return []
        return r.json().get("actions", [])


    def _format_activity(actions: list[dict[str, Any]]) -> str:
        """Raw actions -> the short text block the LLM prompt expects. This is
        the on-the-fly equivalent of what `AsyncContextStore.get()` used to
        return from a local buffer — now computed straight from whatever the
        connector just handed back."""
        return "\n".join(
            f"[{a.get('index')}] {a.get('title')} — {a.get('description')} ({a.get('canonical_url')})"
            for a in actions
        )


    async def fetch_recent_activity_text(user_id: str) -> str:
        return _format_activity(await fetch_recent_activity_raw(user_id))
    ```
  </Accordion>
</AccordionGroup>

<Note>
  **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.
</Note>

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

<AccordionGroup>
  <Accordion title="copilot_server.py — per-user state (expand to copy)">
    ```python theme={null}
    _user_emails: dict[str, str] = {}
    _user_states: dict[str, SessionState] = {}

    def _user_state(user_id: str) -> SessionState:
        if user_id not in _user_states:
            # Demo-friendly timeouts so you can iterate quickly while testing
            # Step 2's proactive triggers. Production: use ~60.0 / ~120.0 so
            # the FSM doesn't drift out of REACTIVE mid-conversation.
            _user_states[user_id] = SessionState(
                session_id=user_id, interaction_timeout_s=10.0, cooldown_period_s=20.0,
            )
        return _user_states[user_id]
    ```
  </Accordion>
</AccordionGroup>

<Note>
  **`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.
</Note>

### 2d. Helper — name from email

<AccordionGroup>
  <Accordion title="copilot_server.py — _name_from_email (expand to copy)">
    ```python theme={null}
    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 None
    ```
  </Accordion>
</AccordionGroup>

<Note>
  **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.
</Note>

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

<AccordionGroup>
  <Accordion title="copilot_server.py reply prompt and endpoint (expand to copy)">
    ```python theme={null}
    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" context

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

    ## 🌐 Language

    Respond 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 shows
    they 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."""

    async def _build_assembly(user_id: str, query: str) -> ChatContextAssembly:
        return ChatContextAssembly(
            recent_activity=await fetch_recent_activity_text(user_id),
            kb_records_text="",
            conversation_history_text="",
            user_message=query,
        )

    app = FastAPI(title="Autoplay × Rasa bridge")

    @app.get("/healthz")
    async def healthz():
        return {"status": "ok"}

    @app.get("/reply/{user_id}")
    async def get_reply(user_id: str, query: str, email: str | None = None) -> dict[str, Any]:
        if not query:
            raise HTTPException(status_code=400, detail="query required")
        if email:
            _user_emails[user_id] = email  # optional — see the Note in 2d
        assembly = await _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()),
        }
    ```
  </Accordion>
</AccordionGroup>

<Note>
  **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).
</Note>

### Start the bridge

```bash theme={null}
cd ~/your-copilot/bridge
uv run uvicorn copilot_server:app --host 0.0.0.0 --port 8090
```

You should see uvicorn's normal startup log — no "connected" line to wait for, since there's no connection to establish:

```
INFO:     Started server process [12345]
INFO:     Uvicorn running on http://0.0.0.0:8090 (Press CTRL+C to quit)
```

Smoke-test:

```bash theme={null}
curl http://localhost:8090/healthz
# {"status":"ok"}
```

Click around in your app for \~30 seconds, then re-check:

```bash theme={null}
curl "http://localhost:8090/reply/YOUR_USER_ID?query=what+did+i+just+do"
# {"reply":"You're already on the upgrade page — just pick Pro and hit Confirm.", "name":null, "has_activity":true}
```

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.

```bash theme={null}
mkdir -p ~/your-copilot/rasa-bot/{actions,data}
cd ~/your-copilot/rasa-bot
```

Create `rasa-bot/docker-compose.yml`:

<AccordionGroup>
  <Accordion title="rasa-bot/docker-compose.yml (expand to copy)">
    ```yaml theme={null}
    services:
      rasa:
        image: rasa/rasa:3.6.21-full
        platform: linux/amd64                 # forces amd64 on Apple Silicon
        ports: ["5005:5005"]
        volumes: ["./:/app"]
        command:
          - run
          - --enable-api
          - --cors
          - "*"
          - --endpoints
          - endpoints.docker.yml
        depends_on: [action-server]

      action-server:
        build:
          context: ./actions
          dockerfile: Dockerfile
        platform: linux/amd64
        ports: ["5055:5055"]
        environment:
          - BRIDGE_URL=http://host.docker.internal:8090
        extra_hosts:
          - "host.docker.internal:host-gateway"
        volumes:
          - ./actions:/app/actions
    ```
  </Accordion>
</AccordionGroup>

Create `rasa-bot/endpoints.docker.yml`:

```yaml theme={null}
action_endpoint:
  url: "http://action-server:5055/webhook"
```

Create `rasa-bot/credentials.yml`:

```yaml theme={null}
rest:

socketio:
  user_message_evt: user_uttered
  bot_message_evt: bot_uttered
  session_persistence: true
  metadata_key: customData         # surfaces widget customData on tracker
```

<Warning>
  **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`.
</Warning>

Create `rasa-bot/config.yml` (NLU + policies):

<AccordionGroup>
  <Accordion title="rasa-bot/config.yml (expand to copy)">
    ```yaml theme={null}
    recipe: default.v1
    language: en

    pipeline:
      - name: WhitespaceTokenizer
      - name: RegexFeaturizer
      - name: LexicalSyntacticFeaturizer
      - name: CountVectorsFeaturizer
      - name: CountVectorsFeaturizer
        analyzer: char_wb
        min_ngram: 1
        max_ngram: 4
      - name: DIETClassifier
        epochs: 100
        constrain_similarities: true
      - name: EntitySynonymMapper
      - name: ResponseSelector
        epochs: 100
        constrain_similarities: true
      - name: FallbackClassifier
        threshold: 0.3
        ambiguity_threshold: 0.1

    policies:
      - name: MemoizationPolicy
      - name: RulePolicy
      - name: UnexpecTEDIntentPolicy
        max_history: 5
        epochs: 100
      - name: TEDPolicy
        max_history: 5
        epochs: 100
        constrain_similarities: true

    assistant_id: autoplay-copilot
    ```
  </Accordion>
</AccordionGroup>

Create `rasa-bot/domain.yml`:

<AccordionGroup>
  <Accordion title="rasa-bot/domain.yml (expand to copy)">
    ```yaml theme={null}
    version: "3.1"
    intents:
      - greet
      - goodbye
      - bot_challenge
      - ask_what_just_happened
      - ask_help_current_page
      - nlu_fallback

    responses:
      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
    ```
  </Accordion>
</AccordionGroup>

Now the three training files under `rasa-bot/data/`.

Create `rasa-bot/data/nlu.yml`:

<AccordionGroup>
  <Accordion title="rasa-bot/data/nlu.yml (expand to copy)">
    ```yaml theme={null}
    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
    ```
  </Accordion>
</AccordionGroup>

Create `rasa-bot/data/rules.yml`:

<AccordionGroup>
  <Accordion title="rasa-bot/data/rules.yml (expand to copy)">
    ```yaml theme={null}
    version: "3.1"

    rules:
      - rule: Say hello on greet
        steps:
          - intent: greet
          - action: utter_greet

      - rule: Say goodbye
        steps:
          - intent: goodbye
          - action: utter_goodbye

      - rule: Answer bot challenge
        steps:
          - intent: bot_challenge
          - action: utter_iamabot

      - rule: Recap recent activity
        steps:
          - intent: ask_what_just_happened
          - action: action_recent_activity

      - rule: Help on current page
        steps:
          - intent: ask_help_current_page
          - action: action_help_current_page

      - rule: Free-form question (LLM fallback)
        steps:
          - intent: nlu_fallback
          - action: action_ask_llm
    ```
  </Accordion>
</AccordionGroup>

Create `rasa-bot/data/stories.yml`:

<AccordionGroup>
  <Accordion title="rasa-bot/data/stories.yml (expand to copy)">
    ```yaml theme={null}
    version: "3.1"

    stories:
      - story: happy path — greet then recap
        steps:
          - intent: greet
          - action: utter_greet
          - intent: ask_what_just_happened
          - action: action_recent_activity

      - story: stuck on page
        steps:
          - intent: greet
          - action: utter_greet
          - intent: ask_help_current_page
          - action: action_help_current_page
    ```
  </Accordion>
</AccordionGroup>

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.

***

## ⚡ 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`:

```dockerfile theme={null}
# rasa-bot/actions/Dockerfile — adds `httpx` (needed to call the bridge),
# which the base `rasa/rasa-sdk:3.6.2` image doesn't ship.
FROM rasa/rasa-sdk:3.6.2

USER root
RUN pip install --no-cache-dir httpx==0.27.2
USER 1001
```

<Warning>
  **`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.
</Warning>

<AccordionGroup>
  <Accordion title="rasa-bot/actions/actions.py (expand to copy)">
    ```python theme={null}
    """Rasa custom actions — autoplay-native.

    The bridge owns the SDK pipeline. Rasa actions just GET /reply.
    """

    from __future__ import annotations
    import os
    from typing import Any
    import httpx
    from rasa_sdk import Action, Tracker
    from rasa_sdk.executor import CollectingDispatcher

    BRIDGE_URL = os.environ.get("BRIDGE_URL", "http://host.docker.internal:8090")
    TIMEOUT_S = float(os.environ.get("BRIDGE_TIMEOUT_S", "12"))


    def _user_id(tracker):
        metadata = (tracker.latest_message or {}).get("metadata") or {}
        custom = metadata.get("customData") or {}
        return (
            metadata.get("userId")
            or custom.get("userId")
            or tracker.sender_id
            or "anonymous"
        )


    def _user_email(tracker):
        """Optional — only used for the bridge's first-name personalization
        (Step 5, section 2d). Returns None if the widget never set it, which
        is fine: the bridge just skips the "User's first name" hint."""
        metadata = (tracker.latest_message or {}).get("metadata") or {}
        custom = metadata.get("customData") or {}
        return metadata.get("email") or custom.get("email")


    def _user_text(tracker):
        return (tracker.latest_message or {}).get("text") or ""


    async def _ask_bridge(user_id, query, email=None):
        params = {"query": query}
        if email:
            params["email"] = email
        async with httpx.AsyncClient(timeout=TIMEOUT_S) as client:
            r = await client.get(f"{BRIDGE_URL}/reply/{user_id}", params=params)
            r.raise_for_status()
            return r.json()


    async def _reply_via_bridge(dispatcher, tracker, fallback):
        try:
            data = await _ask_bridge(
                _user_id(tracker), _user_text(tracker), _user_email(tracker),
            )
            text = data.get("reply") or fallback
        except Exception as exc:
            print(f"[action] bridge call failed: {exc}", flush=True)
            text = fallback
        dispatcher.utter_message(text=text)


    class ActionAskLLM(Action):
        def name(self): return "action_ask_llm"
        async def run(self, dispatcher, tracker, domain):
            await _reply_via_bridge(
                dispatcher, tracker,
                "I'm not sure I caught that. Try asking what you just did, or for help.",
            )
            return []


    class ActionRecentActivity(Action):
        def name(self): return "action_recent_activity"
        async def run(self, dispatcher, tracker, domain):
            await _reply_via_bridge(
                dispatcher, tracker,
                "I haven't seen activity in the last few minutes. Click around and ask again.",
            )
            return []


    class ActionHelpCurrentPage(Action):
        def name(self): return "action_help_current_page"
        async def run(self, dispatcher, tracker, domain):
            await _reply_via_bridge(
                dispatcher, tracker,
                "I don't know what page you're on yet. Click anywhere and ask again.",
            )
            return []
    ```
  </Accordion>
</AccordionGroup>

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

```bash theme={null}
cd ~/your-copilot/rasa-bot
docker compose run --rm rasa train         # ~1 min, writes models/
docker compose up -d                       # rasa + action-server
```

Verify:

```bash theme={null}
curl -s http://localhost:5005/status      # rasa server
curl -s http://localhost:5055/health      # action server
```

End-to-end test through Rasa's REST channel (no widget yet):

```bash theme={null}
curl -s -X POST http://localhost:5005/webhooks/rest/webhook \
  -H "Content-Type: application/json" \
  -d '{
    "sender": "YOUR_USER_ID",
    "message": "what did i just do",
    "metadata": { "customData": { "userId": "YOUR_USER_ID" } }
  }'
```

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.

```jsx theme={null}
"use client";
import { useEffect } from "react";

export default function RasaWidget({ userId }) {
  useEffect(() => {
    if (typeof window === "undefined") return;
    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 },
          socketUrl: "http://localhost:5005",
          title: "Your Copilot",
          subtitle: "Knows what you just did",
          initPayload: "/greet",
          inputTextFieldHint: "Ask me what you just did, or for help…",
          showFullScreenButton: false,
          params: { storage: "session" },
        },
        null,
      );
    };
    document.body.appendChild(script);
  }, [userId]);
  return null;
}
```

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](./step-2-define-proactive-triggers) 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).

***

## 🛠 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

```bash theme={null}
# Terminal 1 — your web app
# Terminal 2 — bridge
cd ~/your-copilot/bridge && uv run uvicorn copilot_server:app --port 8090

# Terminal 3 — Rasa stack
cd ~/your-copilot/rasa-bot && docker compose up -d
```

After editing `actions/actions.py`:

```bash theme={null}
docker compose restart action-server
```

After editing any Rasa `.yml` (domain, nlu, stories, rules, config):

```bash theme={null}
docker compose run --rm rasa train
docker compose restart rasa
```

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](https://github.com/Autoplay-AI/Autoplay-AI-SDK). Feedback shapes the next version of these docs.
