Full setup guide coming soon
## π€ Step 2 β Connect your AI support agent
This is where Autoplay actually does its job β giving your existing chatbot live context on what the user's been doing, pulled over MCP the moment it needs it.
Intercomβ
Connect Fin via MCP
Mavenβ
Connect via MCP
AdaComing Soon
Connect via API
BotpressComing Soon
Connect via API
Difyβ
Connect via MCP
Crisp AIβ
Connect via MCP
LandbotComing Soon
Connect via API
Rasaβ
Connect via API
Inkeepβ
Connect via API
Tidioβ
Connect via API
Plainβ
Connect via API
ZendeskComing Soon
Full setup guide coming soon
## π Step 3 β Add the proactive layer
Steps 1 and 2 make your AI support agent **reactive** β it can answer with live context, but only when a user asks. This step makes it **proactive**: Autoplay.js watches the same activity stream in the browser and surfaces the next best action β a message or a tour β the moment a milestone happens, without the user asking "what's next?".
Autoplay.js is the piece that actually listens for nudges and dispatches them β through your configured tour provider, or as Autoplay's own nudge card when no provider is set up. Every proactive path (your chatbot's prompt, a plain nudge card, a tour) flows through this one connection.
Before you start, add the credentials created when you registered your product in Step 1 to your server environment:
```bash theme={null}
AUTOPLAY_PRODUCT_ID=your-product-id
AUTOPLAY_MCP_KEY=your-mcp-key
AUTOPLAY_MCP_BASE_URL=https://mcp.autoplay.ai
```
* `AUTOPLAY_PRODUCT_ID` is the issued `product_id` returned by Autoplay registration, not your PostHog or Amplitude project id.
* `AUTOPLAY_MCP_KEY` is the `mcp_key` returned by `onboard_product` during that same setup. Retrieve it from the saved terminal output or the secure 1Password link. If you no longer have it, contact Autoplay rather than registering a second product.
Keep `AUTOPLAY_MCP_KEY` server-side. Never expose it in frontend code or a public environment variable.
### Configure your onboarding
Autoplay uses one onboarding config, keyed by your `product_id`, to decide which milestones to detect and which experience to deliver:
```json theme={null}
{
"product_id": "your-product-id",
"workflows": [
{
"key": "connect_account",
"title": "Connect a social account",
"sort_order": 10,
"required": true,
"detect": "The user connected a social account β e.g. picked a platform and confirmed connecting a handle."
}
],
"tours": [
{
"workflow_key": "connect_account",
"name": "Connect your account",
"flow_id": "your-provider-flow-id"
}
],
"tour_provider": "appcues",
"exploration_gate": {
"min_seconds_on_app": 60,
"min_distinct_features": 3,
"welcome_cooldown_s": 600,
"max_proactive_support": 3
},
"nudge_delivery": {
"default": { "provider": "autoplay" }
}
}
```
`workflows` define the milestones Autoplay detects from live activity. `tours` map each workflow to a flow in your configured `tour_provider`; omit them if you only want Autoplay nudge cards.
Onboarding configuration is not self-serve yetβshare this JSON with the Autoplay team in [Discord](https://discord.gg/jCbR2tQA5).
### 1. Create a nudge-token endpoint
Add a server route that exchanges your MCP key for a short-lived token scoped to the current user:
```jsx theme={null}
export async function POST(request) {
const { userId } = await request.json();
const response = await fetch(
`${process.env.AUTOPLAY_MCP_BASE_URL}/nudges/${process.env.AUTOPLAY_PRODUCT_ID}/token`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.AUTOPLAY_MCP_KEY}`,
},
body: JSON.stringify({ user_id: userId }),
}
);
return Response.json(await response.json());
}
```
### 2. Connect the nudge stream
In your frontend, load Autoplay.js and connect it for the signed-in user. `AUTOPLAY_MCP_BASE_URL` is the production MCP origin, without the `/mcp` transport path:
```javascript theme={null}
const AUTOPLAY_MCP_BASE_URL = "https://mcp.autoplay.ai";
const { Autoplay } = await import(`${AUTOPLAY_MCP_BASE_URL}/autoplay.js`);
const nudgeHandle = Autoplay.connectNudges({
baseUrl: AUTOPLAY_MCP_BASE_URL,
productId: "your-product-id",
userId: "your-signed-in-user-id",
getToken: async () => {
const response = await fetch("/api/nudge-token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId: "your-signed-in-user-id" }),
});
const { token } = await response.json();
return token;
},
onNudge: handleNudge,
onError: (error) => console.warn("[autoplay] nudge stream error:", error),
});
```
Use the same stable user ID your app sends to the session replay provider. `getToken` should fetch a fresh token whenever it is called.
If you use a third-party tour provider, load and identify its SDK before calling `connectNudges` so it is ready when a tour arrives.
### 3. Dispatch nudges
Every nudge includes a `nudge_type`. Tour nudges hand off to the provider and flow configured above; other nudges render an Autoplay card:
```javascript theme={null}
function handleNudge(nudge) {
if (nudge.nudge_type === "tour") {
Autoplay.dispatchNudge(nudge);
nudgeHandle.report("chat_closed");
return;
}
Autoplay.dispatchNudge(nudge, {
onOpen: () => nudgeHandle.report("proactive_accepted_chat"),
onDismiss: () => nudgeHandle.report("proactive_dismissed"),
});
}
```
`Autoplay.dispatchNudge` returns a boolean indicating whether an experience rendered, which is useful to log during setup. A nudge card is the provider-agnostic fallback: Autoplay can offer help without requiring you to build a tour flow.
### 4. Own your tour or chat surface (optional)
By default Autoplay drives the built-in tour providers (Usertour, Appcues) and renders its own nudge card. If you own your tour system or chat UI, register callbacks with `Autoplay.configure` **before** `connectNudges`, and Autoplay routes nudges to them instead:
```javascript theme={null}
Autoplay.configure({
providers: {
// Your own tour system (Joyride, a custom highlighter, β¦).
tour: {
// Each callback also receives the full `nudge` payload as a second arg
// (nudge_type, next_workflow, delivery.ask, β¦) if you want to branch on it.
launch(delivery, nudge) { yourTours.start(delivery.flow_id); },
},
// A chat surface you built on your agent's API.
chat: {
deliver(content, nudge) { chat.injectAssistantMessage(content); }, // show authored copy β NO LLM call
dispatch(text, nudge) { chat.openAndSend(text); }, // send a real user turn to your agent
close(opts) { chat.hide(); }, // dismiss for a tour handoff
},
},
});
```
Resolution order per nudge: your `tour.launch` / `chat.deliver` first, then the built-in providers, then the Autoplay card as a fallback. A callback that returns `false` (or throws) falls through to the next option; returning nothing counts as handled. `chat.deliver` must **not** trigger an LLM turn β it only displays the pre-authored copy; use `chat.dispatch` for a real turn (e.g. when the user accepts a nudge). Register `chat.close` so a tour can dismiss your chat during handoff.
Registering a chat surface **takes over chat nudges from the card in Step 3**: when `chat.deliver` handles a nudge, the Autoplay card is suppressed, so the card's `onOpen` / `onDismiss` reporting (`proactive_accepted_chat` / `proactive_dismissed`) no longer fires for chat nudges. Emit those from your own callbacks instead, using the `report()` handle returned by `connectNudges` β e.g. `nudgeHandle.report("proactive_accepted_chat")` inside `chat.dispatch` (the user accepted), and `nudgeHandle.report("proactive_dismissed")` when the user dismisses your surface. Tour nudges still report as before.
### 5. Verify the connection
Sign in to your app, open the browser console, and confirm the nudge stream connects without calling `onError`. Autoplay can now dispatch provider-backed experiences and its own nudge cards. If you add visual guidance in Step 4, initialize that provider before calling `connectNudges`.
Autoplay.js only delivers the nudge at the right moment β something still has to decide *what to say* and *what shows up on screen*. Most AI support agents (Maven, Intercom's Fin, and others) don't do this on their own: they answer when asked, but they don't watch live activity or open a message by themselves.
To make your chatbot proactive:
1. **Add the proactive prompt for your chatbot platform.** This teaches the agent to offer the next step instead of only answering questions β follow the recipe for your platform from Step 2 above (e.g. Maven's **Add proactive layer** step).
2. **Add a way to show the offer.** Most chatbot platforms don't render their own proactive UI, so use a **user tour provider** (e.g. Appcues) to display the box the user actually sees and can accept or dismiss β or skip the tour provider and let Autoplay's own nudge card handle it (see the **Connect Autoplay.js** tab).
Full walkthrough β the proactive prompt plus the Appcues tour hand-off β in Maven β Add proactive layer.
Once connected, a dispatched nudge card looks like this β Autoplay offering the next step right after the AI agent confirms a milestone, with the user free to accept or dismiss:
If you're using Autoplay's built-in nudge card instead of a tour provider, its copy is customizable with a couple of options:
```javascript theme={null}
Autoplay.dispatchNudge(nudge, {
brandName: "Acme", // replaces the "Autoplay" label on the card β default "Autoplay"
ctaLabel: "Show me", // replaces the accept-button text β default "Show me how"
onOpen: () => nudgeHandle.report("proactive_accepted_chat"),
onDismiss: () => nudgeHandle.report("proactive_dismissed"),
});
```
The card's body text comes from `nudge.message` if your onboarding config sets one; otherwise Autoplay generates it from the milestone (e.g. "Nice progress! π Want me to walk you through **Setting your posting schedule**?"). Position, color, and layout are fixed today β `brandName` and `ctaLabel` are the only visual customization available.
## πΊοΈ Step 4 β Connect visual guidance
Optional. If you want to give your users the ultimate level of guidance, add visual guidance on top β pop-up tours today, and browser agents soon. Wire up a tour provider β Appcues, Usertour, Pendo, and more β so that once a user asks for more help from Step 3's proactive message, the right tour launches from the same event stream.
Trigger Appcues, Pendo, Usertour, and more once a user asks for more guidance β optional
## πΊοΈ What's next
Go deeper on the SDK:
Turn live activity and onboarding context into concise, useful guidance without interrupting or repeating the user.
Explore all fields on ActionsPayload and SummaryPayload
Embed events into a vector store in real time
Connect any agent to pull live activity via the MCP tool
Combine real-time events, memory, and golden paths
For structured logging and `extra` field conventions used across the SDK, see [Logging](/sdk/logging). Release history is on the [Changelog](/changelog).
# Ada tutorial
Source: https://developers.autoplay.ai/recipes/ada/index
Pull live user activity with the Autoplay SDK and surface it to an Ada agent on demand for real-time context.
Pull live user activity with the Autoplay SDK and surface it to an Ada agent on demand for real-time context.
***
## π Prerequisites
Complete the [Quickstart](/quickstart). You should have:
* **PostHog (or Amplitude) in the browser** β snippet installed, `identify` / `setUserId` setting a stable `user_id` on login (and email after login if you use it)
* **Your `product_id` and `mcp_key`** β printed by `onboard_product` in the Quickstart
* **`autoplay-sdk` installed** β and a successful live-activity read (Step 4 of the Quickstart)
* **An Ada account** β with a bot created in the [Ada dashboard](https://app.ada.cx)
***
## The building blocks
Follow the steps in order β each page stands alone so you can ship incrementally.
1. **[Connect real-time events](./step-1-connect-real-time-events)** β Pull live user activity into Ada and wire your bot to answer with that context. *(Coming soon)*
2. **[Define proactive triggers](./step-2-define-proactive-triggers)** β Proactively message users in Ada based on what they're doing in your product. *(Coming soon)*
# Connect real-time events
Source: https://developers.autoplay.ai/recipes/ada/step-1-connect-real-time-events
Pull a user's live activity from Autoplay on demand and inject it into Ada's AI Agent via metaFields.
## β‘ Add this skill
Add the Autoplay Ada skill for an existing Ada AI support agent setup.
```bash CLI theme={null}
uvx --from autoplay-sdk autoplay-install-skills --chatbot ada
```
View the docs β
Fetch this skill when a customer already uses Ada and wants its AI support agent to consume Autoplay live user activity.
```bash cURL theme={null}
curl -s https://developers.autoplay.ai/chatbot-ada/SKILL.md
```
View the skill β
Pull a user's live activity from Autoplay on demand and inject it into Ada's AI Agent via `metaFields` β so every conversation is grounded in what the user is actually doing in your product.
## β¨ Final result
```text theme={null}
User: Is there a way to automatically notify my team when a task is done?
Ada: Yes! You can use Automations for this. Go to the Automations centre
on your board and set up a rule like "When status changes to Done, notify someone."
User: Where is the Automations centre?
```
*No idea the user has already done this manually 9 times. Forces a follow-up.*
```text theme={null}
Live context: Board 4452109 Β· status β "Done" Γ 9 Β· no automation visits
User: Is there a way to automatically notify my team when a task is done?
Ada: Looks like you've been marking items Done manually β you can automate
that with one rule. Click the β‘ lightning bolt at the top of this board β
search "status changes" β pick "When status changes to Done, notify someone"
and select your team. Takes 30 seconds.
```
*Spots the repetitive behaviour, gives the exact click path, skips the follow-up.*
**How this works** β PostHog or Amplitude sends events to Autoplay's ingest endpoint as users act in your product. There's no stream to subscribe to: your backend pulls a user's recent activity **on demand** β a single REST call to Autoplay's live-activity endpoint, keyed by `product_id` + `user_id` β the moment a user opens Ada. That response is formatted into `metaFields` and passed to the Ada embed before β or during β a chat session. Ada's AI Agent reads these variables inside your bot's Processes to personalise responses.
***
## π Prerequisites
Before starting, confirm the following:
* **Ada already integrated on your web app** β the Ada embed script is installed, your bot handle is configured, and Ada is opening chat sessions successfully.
* **PostHog or Amplitude already set up** β complete the [Quickstart](/quickstart) first, with `posthog.identify(user.id)` (or `amplitude.setUserId(user.id)`) setting a stable `user_id` on login.
* **Your `product_id` and `mcp_key`** β printed by `onboard_product` in the Quickstart.
* **`autoplay-sdk` installed** β `pip install autoplay-sdk`
* **A backend you can add one route to** β FastAPI is shown below, but any framework that can make an outbound HTTP call works.
**How identity works** β Autoplay keys a user's live activity by the stable `user_id` your activity source identifies them with β the same id you pass to `posthog.identify(user.id)` or `amplitude.setUserId(user.id)`. There's no session id in the read API: every lookup is `GET /users/{product_id}/{user_id}/live-activity`, scoped only by product and user.
When the user opens Ada, your frontend passes that same `user_id` to your `/context/{user_id}` endpoint (Step 2 below), which fetches their live activity and returns it as `metaFields`.
If a user is anonymous (not yet identified), your activity source still records events under an anonymous id β Autoplay will have context for that id, but it won't be linked to a real user account until you call `identify` / `setUserId`.
```javascript theme={null}
posthog.identify(user.id); // same id you must pass to /context/{user_id}
```
```javascript theme={null}
amplitude.setUserId(user.id); // same id you must pass to /context/{user_id}
```
**How the pieces fit:** your app identifies the user in PostHog/Amplitude β Autoplay stores activity under that `user_id` β your frontend passes the same `user_id` to `/context/{user_id}` before opening Ada β the returned `metaFields` are populated with that user's real activity.
***
## The building blocks
1. **Define Variables in Ada** β Create the Variables your bot Processes will read. Done once in the Ada dashboard.
2. **Serve context to the Ada SDK** β Expose a lightweight endpoint that pulls a user's live activity from Autoplay on demand (a single REST call β no listener process, no local cache) and passes it as `metaFields`.
3. **Use Variables in your Ada Processes** *(Step 2 β coming soon)* β Reference the injected variables in your bot's Process conditions and responses.
***
## π§ Step 1 β Define Variables in Ada
When you pass `metaFields` to the Ada embed, those keys become Variables your bot Processes can read. Create these in your Ada dashboard under **Build β Variables β + New Variable**.
| Variable name (= `metaFields` key) | Type | Description |
| ---------------------------------- | ------ | ------------------------------------------------------------- |
| `user_id` | String | Stable id from your activity source β keys the backend lookup |
| `current_page` | String | URL the user is on, taken from their most recent action |
| `recent_actions` | String | User's recent in-app actions as a numbered list |
Ada `metaFields` keys must **not** include whitespace, emojis, special characters, or periods. Use underscores as shown above. Keys are case-sensitive and must match exactly between the backend response and the frontend `metaFields` object.
We dropped `session_summary` from this table. The live-activity endpoint already returns a bounded, recent window (the `limit` query param), so there's no separate summarisation step to run or store. If you want a narrative summary for longer histories, generate it inside your own `/context/{user_id}` handler and add it back as a metaField β it's optional, not required for this integration to work.
***
## π Step 2 β Serve context to the Ada SDK
Your backend exposes one route: `GET /context/{user_id}`. It calls Autoplay's live-activity endpoint synchronously, formats the result, and returns it. No stream to consume, no worker process, no Redis β the call happens the moment your frontend needs it.
### Install dependencies
```bash theme={null}
pip install autoplay-sdk fastapi uvicorn httpx
```
### FastAPI context endpoint
```python theme={null}
# api.py
import httpx
from fastapi import FastAPI
app = FastAPI()
CONNECTOR_URL = "https://mcp.autoplay.ai" # origin of your mcp_url, without /mcp
PRODUCT_ID = "your-product-id" # from onboard_product (Quickstart)
MCP_KEY = "your-mcp-key" # from onboard_product (Quickstart)
@app.get("/context/{user_id}")
async def context_for_user(user_id: str):
async with httpx.AsyncClient() as client:
res = await client.get(
f"{CONNECTOR_URL}/users/{PRODUCT_ID}/{user_id}/live-activity",
params={"limit": 10},
headers={"Authorization": f"Bearer {MCP_KEY}"},
)
if res.status_code != 200:
return {"user_id": user_id, "current_page": "", "recent_actions": ""}
actions = res.json().get("actions", [])
current_page = actions[-1]["canonical_url"] if actions else ""
recent_actions = "\n".join(
f"[{i + 1}] {a['title']} β {a['description']}"
for i, a in enumerate(actions)
)
return {
"user_id": user_id,
"current_page": current_page,
"recent_actions": recent_actions,
}
```
Protect this endpoint in production. Validate with a session cookie or short-lived signed token tied to the logged-in user β not the raw `user_id` alone, which would let any caller read any user's context.
***
### Add the Ada embed script
Add `data-lazy` to your Ada embed script. This prevents Ada from initialising until you call `adaEmbed.start()`, so you can fetch the user's live activity context first and pass it in on the first open.
```html theme={null}
```
***
### Open Ada with live context
```javascript theme={null}
// support.js
// ββ User identity βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// This MUST be the same stable id you pass to posthog.identify(user.id) or
// amplitude.setUserId(user.id) β not a session id, not an email. Resolve it
// from your own auth/session state.
function getCurrentUserId() {
return window.currentUser?.id ?? null;
}
// ββ Ada initialisation state ββββββββββββββββββββββββββββββββββββββββββββββ
// Do NOT use `window.adaEmbed` to check if Ada is ready.
// With data-lazy, window.adaEmbed exists as soon as the embed script loads β
// BEFORE start() is called. Calling setMetaFields() or toggle() at that point
// silently fails and the widget never opens.
// Use this flag instead, set only inside adaReadyCallback.
let adaReady = false;
// ββ Main: wire to your "Contact Support" button βββββββββββββββββββββββββββ
async function openAdaWithContext() {
const userId = getCurrentUserId();
let meta = {
user_id: userId ?? 'anonymous',
current_page: window.location.pathname,
recent_actions: '',
};
if (userId) {
try {
const res = await fetch(`/context/${userId}`);
if (!res.ok) throw new Error(`Context endpoint ${res.status}`);
const ctx = await res.json();
meta = { ...meta, ...ctx };
} catch (e) {
// Fail open β Ada opens without extra context rather than not opening at all
console.warn('Autoplay context unavailable:', e);
}
}
if (adaReady) {
// Ada already initialised β update metaFields in-place then toggle open
await window.adaEmbed.setMetaFields(meta);
await window.adaEmbed.toggle();
return;
}
// First open β initialise Ada with metaFields pre-loaded
await window.adaEmbed.start({
handle: 'YOUR-BOT-HANDLE',
metaFields: meta,
adaReadyCallback: () => {
adaReady = true; // mark ready BEFORE toggling
window.adaEmbed.toggle();
},
});
}
// ββ SPA: update current_page on every navigation βββββββββββββββββββββββββ
// React Router and other SPA frameworks navigate via history.pushState(),
// which does NOT fire the popstate event. Patch pushState to emit a custom
// event so all navigations are caught β not just browser back/forward.
(function patchHistory() {
const _push = history.pushState.bind(history);
history.pushState = function (...args) {
_push(...args);
window.dispatchEvent(new Event('spa:navigate'));
};
})();
window.addEventListener('spa:navigate', async () => {
if (!adaReady) return;
await window.adaEmbed.setMetaFields({ current_page: window.location.pathname });
});
// ββ Ada event subscriptions βββββββββββββββββββββββββββββββββββββββββββββββ
// adaSettings must be defined BEFORE the
```
If you're loading it yourself (e.g. in a Next.js effect) instead of a static `
```
## 2. Identify the user
Call this once the user is authenticated. Use the same `userId` you send to PostHog so Autoplay can match sessions correctly.
```javascript theme={null}
chmln.identify(userId, {
email: user.email,
name: user.displayName,
created: user.createdAt, // ISO 8601
role: user.role,
});
```
## 3. Create a tour in Chameleon
1. In the Chameleon dashboard go to **Experiences β Tours** and click **+ Create a Tour**.
2. Select a tour type β **Announcement** starts automatically when a user meets targeting rules; **Walkthrough** starts manually via a Launcher or short-link.
3. Build your steps, set any targeting rules (page URL, user segment, etc.), and publish the tour.
4. Note the **Tour ID** displayed below the tour name (`ID: `). Pass this as the `flow_id` when triggering.
## 4. Trigger the tour
In your `onmessage` handler from [step 3 of the manual path](/recipes/user-tour/overview#3-understanding-the-payload), add the Chameleon trigger call:
```javascript theme={null}
events.onmessage = (event) => {
const payload = JSON.parse(event.data);
if (payload.type !== "usertour_trigger") return;
const mySessionId = posthog?.get_session_id?.() ?? null;
if (!mySessionId || payload.session_id !== mySessionId) return;
chmln.show(payload.flow_id);
};
```
The `flow_id` in the payload maps to the **Tour ID** of your Chameleon tour, visible in the URL when editing the tour in the Chameleon dashboard.
# Connect real-time events
Source: https://developers.autoplay.ai/recipes/crisp-ai/step-1-connect-real-time-events
Give Hugo live awareness of what each user is doing in your app β Hugo pulls it on demand via the Autoplay MCP server.
## β‘ Add this skill
Add the Autoplay Crisp Hugo skill for an existing Crisp Hugo AI support agent setup.
```bash CLI theme={null}
uvx --from autoplay-sdk autoplay-install-skills --chatbot crisp
```
View the docs β
Fetch this skill when a customer already uses Crisp Hugo and wants its AI support agent to consume Autoplay live user activity.
```bash cURL theme={null}
curl -s https://developers.autoplay.ai/chatbot-crisp/SKILL.md
```
View the skill β
**Hugo** (Crisp's AI agent) pulls a user's recent in-app activity on demand via the **Autoplay MCP server** β Hugo calls it the moment it needs context to answer. One MCP connection, one tool (`get_live_user_activity`), and a verified identity so Hugo asks for the right user.
This guide assumes you **already have Hugo set up** in your Crisp workspace. If you don't have it configured yet, see [Crisp's own Hugo setup guide](https://help.crisp.chat/en/article/getting-started-with-hugo-ai-agent-w6gbux/) to set it up first, then come back here. Adding external MCP servers requires Crisp's **Plugin-tier** API access β check your plan before starting.
**What Autoplay needs from your Crisp setup:**
* **`mcp_key`** β printed by your own `onboard_product` call (see [Quickstart](/quickstart)), not something Crisp or your activity provider issues.
* **A stable `user_id`** you can push into `session:data` β the same id your activity source (PostHog/Amplitude) identifies the user with.
## π¬ End-to-end walkthrough
***
## π Add the Autoplay MCP server
In your Crisp dashboard, go to **AI Agent** (left sidebar) β **Integrations & MCP** (under Automate). Scroll to the **External MCP servers** section and click **Add MCP server**.
In the modal that appears:
1. **MCP Server URL:** `https://mcp.autoplay.ai/mcp`
2. Click **Authentication** to expand it β set **Authentication Method** to **Bearer Token** β enter your `mcp_key` in the **Bearer Token** field.
Click **Add MCP server**. Once connected, the server appears in your External MCP servers list showing **Online**.
## βοΈ Configure the server β click Manage
Click **Manage** on the server. The Manage page has two places where you write descriptions β one at the **server level** and one at the **tool level**. Both matter.
### 1 β Server Description (Connection details)
In the **Connection details** section, fill in:
* **Name:** `App live activity`
* **Description:** This is the top-level prompt β Hugo reads it to decide when this MCP server is relevant at all. Keep it short and focused on the scenario:
```text theme={null}
Use this server to get a user's recent in-app activity, including
pages they visited, buttons they clicked, and actions they
took. Call it when a user seems stuck, asks about something
they were just doing, or when knowing their recent navigation
would help you give a better answer.
```
### 2 β Tool Description (MCP tools selector)
Scroll down to **MCP tools selector** and check **`get_live_user_activity`** to enable it. The tool card shows a description field β this is the detailed prompt that tells Hugo exactly when and how to call this specific tool (equivalent to the **Fin tab** prompt in Intercom).
Click the **edit icon (βοΈ)** on the `get_live_user_activity` card and set the description to:
```text theme={null}
Always call this tool before responding to any user message.
Use the returned user activity data, features visited,
actions taken, workflows completed, to understand what the
user has already done and what they haven't. Use this context
to better answer their question, surface context they didn't
mention, and diagnose what they're actually stuck on,
because sometimes users don't phrase what they need in the
best way since they may be stuck and don't understand the
product like an expert.
The product_id is always: YOUR_PRODUCT_ID
```
Replace `YOUR_PRODUCT_ID` with the real `product_id` printed by your own `onboard_product` call (see [Quickstart](/quickstart)) β not from Crisp or your Activity provider's dashboard. Including it in the tool description is how you pass a fixed value β there is no separate "fixed value" input field in Hugo.
## ποΈ Configure the parameters
Under **Parameters** on the tool card, each input has two options: **Let Hugo decide** or **Use an attribute**.
**product\_id** β set to **Let Hugo decide**
Hugo reads your product id from the description above (the `The product_id is always: ...` line) and passes automatically.
**user\_id** β set to **Use an attribute** β select the attribute that holds your app's stable user id
The dropdown shows contact attributes available in the conversation (e.g. **User email**). Choose the one that matches the id your activity source identifies the user with. See *Identity* below for how to make the right attribute available.
**limit** β leave as **Let Hugo decide**.
For **user\_id**, do not use email unless email is literally the stable id your activity source identifies the user with. Activity is stored under that stable id β a mismatch means Hugo reads an empty bucket.
## π Identity β make the user id available as an attribute
Hugo can only pass the right `user_id` if the Crisp session carries it. Set it from your frontend via the Crisp JS SDK **after the user logs in**, using `session:data`:
```javascript theme={null}
// After login β push your app's stable user id into the Crisp session.
// The key name ("user_id") becomes the attribute name visible in Hugo's parameter dropdown.
$crisp.push(["set", "session:data", [[["user_id", currentUser.id]]]]);
// Optionally set standard Crisp contact fields too:
$crisp.push(["set", "user:email", [currentUser.email]]);
$crisp.push(["set", "user:nickname", [currentUser.name]]);
```
After you push `session:data` with a `user_id` key, that key appears in Hugo's **Use an attribute** dropdown. Select it for the `user_id` parameter.
The value you push **must exactly equal** the id your activity source uses:
```javascript theme={null}
// These two must be identical:
posthog.identify(currentUser.id); // activity source
$crisp.push(["set", "session:data", [[["user_id", currentUser.id]]]]); // Crisp session
```
```javascript theme={null}
// These two must be identical:
amplitude.setUserId(currentUser.id); // activity source
$crisp.push(["set", "session:data", [[["user_id", currentUser.id]]]]); // Crisp session
```
```javascript theme={null}
// TODO: Replace this with the user identification method
// provided by your activity source.
// The user ID sent to your activity source and Crisp
// must be exactly the same value.
analytics.identify(currentUser.id);
$crisp.push(["set", "session:data", [[["user_id", currentUser.id]]]]);
```
**How the pieces fit:** your frontend identifies the user in your activity source (PostHog or Amplitude) β Autoplay stores activity under that id β your frontend also pushes `session:data` with that same id β Hugo reads the attribute and fetches activity for it β the buckets match.
## β Test the full loop
1. **Log in** to your app as a test user β fires your activity source's identify call and sets `session:data` with `user_id`.
2. **Click around** β visit a couple of pages, click a button, submit a form.
3. **Open the Crisp chat widget** as that same logged-in user.
4. **Ask Hugo:** *"What have I been doing in the app recently?"*
5. Hugo calls **`get_live_user_activity`** and answers with **what you actually just did**.
Common issues:
* **401 / auth error** β the Bearer token is missing or incorrect, revisit *Add the Autoplay MCP server* above.
* **Empty activity returned** β identity is working but that user has no recent activity yet. Browse around in your app first, then re-test.
* **Wrong user's activity** β the `user_id` in `session:data` doesn't match the id your activity source uses. Check both are identical.
**"No recent activity" = identity mismatch.** Confirm the **same** value in all three:
1. the stable id your activity source identifies the user with (PostHog: `posthog.identify(id)`, Amplitude: `amplitude.setUserId(id)`),
2. the `user_id` key in `$crisp.push(["set", "session:data", ...])`,
3. the attribute selected for the `user_id` parameter in Hugo's tool config.
If they don't match, activity is stored under one key and fetched with another, and the lookup comes back empty.
Once Hugo is answering with real activity, jump into our [Discord](https://discord.gg/jCbR2tQA5) β we'll confirm the tool is pulling activity cleanly and help you tune the trigger description.
***
**Next:** [Step 2 β Define proactive triggers](./step-2-define-proactive-triggers)
# Datadog β How to setup
Source: https://developers.autoplay.ai/recipes/datadog/how-to-setup
Learn how to set up live user activity from Datadog to feed as context to your support AI agent using the Autoplay SDK.
This tutorial is coming soon. Check back shortly for the full guide.
# Connect real-time events
Source: https://developers.autoplay.ai/recipes/dify-tutorial/step-1-connect-real-time-events
Give your Dify Agent live awareness of what each user is doing β the agent pulls it on demand via the Autoplay MCP server.
## β‘ Add this skill
Add the Autoplay Dify skill for an existing Dify AI support agent setup.
```bash CLI theme={null}
uvx --from autoplay-sdk autoplay-install-skills --chatbot dify
```
View the docs β
Fetch this skill when a customer already uses Dify and wants its AI support agent to consume Autoplay live user activity.
```bash cURL theme={null}
curl -s https://developers.autoplay.ai/chatbot-dify/SKILL.md
```
View the skill β
Your **Dify Agent** pulls a user's recent in-app activity on demand via the **Autoplay MCP server** β the agent calls it the moment it needs context to answer. One MCP connection, one tool (`get_live_user_activity`), and a user id wired through the agent's input variables so the right user's activity is always fetched.
This guide assumes you **already have a Dify Agent app set up**. If you don't have one yet, see [Dify's own Agent docs](https://docs.dify.ai/en/cloud/use-dify/build/agent) to create one first, then come back here.
**What Autoplay needs from your Dify setup:**
* **`mcp_key`** β printed by your own `onboard_product` call (see [Quickstart](/quickstart)), not something Dify issues.
* **A stable `user_id` input variable** wired into the agent β the same id your activity source (PostHog/Amplitude) identifies the user with.
## π¬ End-to-end walkthrough
***
## π Add the Autoplay MCP server
In your Dify workspace, go to **Tools** (top navigation) β **MCP** tab β **Add MCP Server (HTTP)**.
In the modal that appears, fill in:
* **Server URL:** `https://mcp.autoplay.ai/mcp`
* **Name & Icon:** `Autoplay live activity`
* **Server Identifier:** `autoplay-live-activity` *(lowercase letters, numbers, underscores, hyphens β up to 24 characters)*
Then click the **Headers** tab and click **+ Add Header** to add the Bearer token:
| Key | Value |
| --------------- | --------------------- |
| `Authorization` | `Bearer YOUR_MCP_KEY` |
Replace `YOUR_MCP_KEY` with the real `mcp_key` printed by your own `onboard_product` call (see [Quickstart](/quickstart)) β don't paste the placeholder text literally.
Click **Add & Authorize**. Dify connects to the server and shows it as **Authorized** with **1 tool included** β `get_live_user_activity`.
## π€ Create an Agent app
In **Studio**, click **Create from Blank** β select **Agent** as the app type. Give it a name (e.g. `Support Agent`) and create it.
Inside the Agent's **Orchestrate** view, find the **Tools** section and add **`get_live_user_activity`** from your `Autoplay live activity` MCP server. The tool's parameters (`product_id`, `user_id`, `limit`) come from the MCP server definition β no manual configuration needed.
## π¬ Set the Agent instructions
The **Instructions** field (the system prompt) is where you tell the agent when to call the tool and how to use the activity data. Replace or append with:
```text theme={null}
Always call get_live_user_activity before responding to any
user message. Use the returned activity data β features
visited, actions taken, workflows completed β to understand
what the user has already done and what they haven't.
Use this context to better answer their question, surface
context they didn't mention, and diagnose what they're
actually stuck on.
When calling get_live_user_activity:
- product_id is always: YOUR_PRODUCT_ID
- user_id is: {{user_id}}
```
Replace `YOUR_PRODUCT_ID` above with the real `product_id` printed by
your own `onboard_product` call (see [Quickstart](/quickstart)) β not
from your Activity provider's dashboard. Unlike `{{user_id}}`, this is
literal text you edit directly in the instructions, not a Dify input
variable β see *Identity* below for why only `user_id` is wired that way.
## π Identity β wire the user id into the agent
The agent needs the current user's stable id to pass as `user_id` to the MCP tool. In Dify, you do this with an **input variable**.
### 1. Add the input variable
In the Agent's **Orchestrate** view, find **Variables** (or **Inputs**) and add:
* **Variable name:** `user_id`
* **Type:** String
### 2. Reference it in the instructions
The `{{user_id}}` in the instructions above is how Dify substitutes the real value at runtime β the agent reads it and passes it as the `user_id` parameter when calling the tool. `product_id` is **not** a template variable: Dify has no equivalent per-request substitution for it, so the `YOUR_PRODUCT_ID` text you set in Step π¬ above is a fixed literal value edited directly into the instructions, not something Dify fills in per user. After that publish the agent.
### 3. Pass it when calling the Dify API
When your frontend calls the Dify API to start or continue a conversation, include `user_id` in the `inputs` object:
```javascript theme={null}
const response = await fetch(
"https://api.dify.ai/v1/chat-messages",
{
method: "POST",
headers: {
"Authorization": `Bearer ${DIFY_APP_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
inputs: {
// Must equal the id passed to posthog.identify(...)
user_id: posthog.get_distinct_id(),
},
query: userMessage,
conversation_id: existingConversationId,
user: currentUser.id,
}),
}
);
```
```javascript theme={null}
const response = await fetch(
"https://api.dify.ai/v1/chat-messages",
{
method: "POST",
headers: {
"Authorization": `Bearer ${DIFY_APP_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
inputs: {
// Must equal the id passed to amplitude.setUserId(...)
user_id: currentUser.id,
},
query: userMessage,
conversation_id: existingConversationId,
user: currentUser.id,
}),
}
);
```
The value in `inputs.user_id` **must exactly equal** the id your activity source identifies the user with:
**How the pieces fit:** your frontend identifies the user
in your activity source β Autoplay stores activity under
that id β your frontend passes that same id as
`inputs.user_id` in the Dify API call β the agent reads
`{{user_id}}` from inputs and passes it to the MCP tool
β the buckets match.
Do not use email as `user_id` unless email is literally
the stable id your activity source uses.
Activity is stored under the stable id β a mismatch means
the agent fetches an empty bucket.
## β Test the full loop
1. **Log in** to your app as a test user β fires your activity source's identify call.
2. **Click around** β visit a couple of pages, click a button, submit a form.
3. **Open the Agent** (via your frontend or Dify's Preview panel) passing the matching `user_id` in `inputs`.
4. **Ask the agent:** *"What have I been doing in the app recently?"*
5. The agent calls **`get_live_user_activity`** and answers with **what you actually just did**.
Common issues:
* **401 / Unauthorized** β the `Authorization` header is missing or the token is wrong β revisit *Add the Autoplay MCP server* above.
* **Empty activity returned** β identity is working but that user has no recent activity yet. Browse around in your app first, then re-test.
* **Wrong user's activity** β the `user_id` in `inputs` doesn't match the id your activity source uses. Check both are identical.
**"No recent activity" = identity mismatch.** Confirm the
**same** value in all three:
1. the stable id your activity source identifies the user
with (PostHog: `posthog.identify(id)`,
Amplitude: `amplitude.setUserId(id)`),
2. the `user_id` passed in `inputs` when calling
the Dify API,
3. the `{{user_id}}` variable referenced in the
agent instructions.
If they don't match, activity is stored under one key
and fetched with another, and the lookup comes back empty.
Once the agent is answering with real activity, jump into our [Discord](https://discord.gg/jCbR2tQA5) β we'll confirm the tool is pulling activity cleanly and help you tune the instructions.
***
**Next:** [Step 2 β Define proactive triggers](./step-2-define-proactive-triggers)
# FullStory β How to setup
Source: https://developers.autoplay.ai/recipes/fullstory/how-to-setup
Learn how to set up live user activity from FullStory to feed as context to your support AI agent using the Autoplay SDK.
## β‘ Add this skill
Add the Autoplay FullStory session replay provider skill for an existing FullStory setup.
```bash CLI theme={null}
uvx --from autoplay-sdk autoplay-install-skills --user-activity fullstory
```
View the docs β
Fetch this skill when a customer already uses FullStory as a session replay provider and wants Autoplay live user activity.
```bash cURL theme={null}
curl -s https://developers.autoplay.ai/activity-fullstory/SKILL.md
```
View the skill β
# Streaming every user click to Autoplay via FullStory Streams
This tutorial sets up a FullStory Stream that fires a webhook to the Autoplay SDK every time a user clicks anything in your product β capturing what they clicked, when, and on which page. Everything is configured inside the FullStory dashboard. No code changes to your frontend are required.
***
## Prerequisites
Before starting, confirm the following:
* **FullStory is already installed and capturing sessions** in your product. Verify by running `FS('getSession', { format: 'id' })` in the browser console β if it returns a string, capture is active.
* **Anywhere: Activation is included in your FullStory plan.** Streams is available on Enterprise and Advanced plans. Check under **Settings β Anywhere β Activation** β if the menu item is missing, confirm your plan tier with your FullStory account manager.
* **You have Admin or Architect role in FullStory.** Required to create and manage Streams.
* **You have your Autoplay ingest endpoint URL and auth token.** You'll enter these into the Stream destination in Step 2. Retrieve them from the Autoplay dashboard.
* **Check your Activation Quota before going live.** Single-event Streams consume 1/100th of an Activation per trigger. At high click volumes this quota can be significant β confirm your limit with your FullStory account manager. See the [Activation Quota docs](https://help.fullstory.com/hc/en-us/articles/33633977965719).
***
## Step 1 β Navigate to Streams
In the FullStory dashboard:
```
Settings β Anywhere β Activation β Create Stream
```
Give the Stream a name and description that makes it easy to identify later:
* **Name:** `autoplay-every-click`
* **Description:** `Streams every user click event to the Autoplay SDK β captures target text, page URL, and timestamp.`
***
## Step 2 β Configure the destination
This tells FullStory where to POST the data. Set it up as follows:
| Field | Value |
| ---------------- | -------------------------------------------------- |
| Destination type | HTTP Endpoint |
| Request Method | POST |
| API Endpoint URL | Your Autoplay ingest URL |
| Authentication | Bearer token (or whichever auth Autoplay requires) |
Click **Create connection**, select your authentication type, and enter the credentials. Once saved, this connection can be reused for any additional Streams you create later.
> Get the Autoplay ingest endpoint URL and auth token from the Autoplay SDK setup guide: [https://developers.autoplay.ai/recipes/intercom-tutorial](https://developers.autoplay.ai/recipes/intercom-tutorial)
***
## Step 3 β Define the trigger
This is what causes the Stream to fire. You want it to fire on every single click, across every page, for every user.
1. Under **Definition**, click **Select an event**
2. Choose **Element Clicked**
3. Do **not** add any dependent criteria or filters β leaving it empty means it matches every click, not just clicks on specific elements
4. Under **How often should the definition match?** select **On every event**
> **"On every event" is critical here.** The alternative β "Once per session" β would only fire once per user session regardless of how many times they click. You want Autoplay to receive a signal for every individual click.
The definition should look like this when complete:
```
Event: Element Clicked
Filters: (none)
Frequency: On every event
```
***
## Step 4 β Configure the field mapping
This defines exactly what data gets sent to Autoplay in each webhook payload. Switch to the **JSON view** in the Field Mapping section and paste the following:
```json theme={null}
{
"target_text": ["var", "event.0.target_text"],
"element_name": ["var", "event.0.element_name"],
"page_url": ["var", "event.0.url"],
"timestamp": ["var", "event.0.event_time"],
"timestamp_unix": ["toUnixTimestamp", ["var", "event.0.event_time"]],
"session_replay_url": ["var", "event.0.app_url_event"],
"user_id": ["var", "event.0.user_id"],
"user_email": ["var", "event.0.user_email"],
"session_id": [
"concat",
["var", "event.0.device_id"],
"%3A",
["var", "event.0.session_id"]
]
}
```
### What each field captures
| Field | FullStory source | What it tells Autoplay |
| -------------------- | --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `target_text` | `event.0.target_text` | The visible text of the element clicked β e.g. `"Export CSV"`, `"Save changes"`. This is the primary signal for the LLM to understand intent. |
| `element_name` | `event.0.element_name` | The name FullStory assigns to the element β useful when `target_text` is empty (e.g. icon buttons). |
| `page_url` | `event.0.url` | The full URL of the page where the click happened. |
| `timestamp` | `event.0.event_time` | ISO 8601 UTC timestamp of when the click occurred β e.g. `"2025-10-21T14:17:34.073Z"`. |
| `timestamp_unix` | converted from `event_time` | Unix timestamp version β easier to sort and compare programmatically. |
| `session_replay_url` | `event.0.app_url_event` | A deep link that opens the FullStory session replay at the exact moment this click happened. Invaluable for debugging. |
| `user_id` | `event.0.user_id` | The user ID from your system, if `setIdentity` has been called. Links the click to a known user. |
| `user_email` | `event.0.user_email` | The user's email, if provided via `setIdentity`. |
| `session_id` | `device_id` + `session_id` concatenated | The composite `{device_id}:{session_id}` format required by the FullStory Server API. Pass this value when calling the Sessions API or when keying Autoplay's context store. |
> **Why `target_text` and `element_name` together?** `target_text` captures the visible label on the element β the text the user sees. `element_name` is the name FullStory uses internally, which is often derived from `aria-label`, `id`, or element attributes when there's no visible text. Sending both means Autoplay always has a readable identifier even for icon-only buttons or inputs.
> **Why `session_replay_url` using `app_url_event` and not `app_url_session`?** The `app_url_event` field deep-links to this specific click in the recording, not just the start of the session. Every payload Autoplay receives has a one-click path to the exact frame where that click happened.
***
## Step 5 β Send a test and verify
Before saving, use the built-in **Send Test** feature to confirm the connection is working:
1. Click **Send Test**
2. Under **Request**, review the sample payload β check the field names match what you configured
3. Under **Server Response**, confirm you see `200 OK`
If the test returns an error:
* Double-check the Autoplay endpoint URL for typos
* Confirm the auth token is correct and hasn't expired
* Make sure Autoplay's endpoint accepts `POST` requests with `Content-Type: application/json`
***
## Step 6 β Save and activate
Click **Save**. The Stream is now live.
Every time any user clicks anything in the product, FullStory will POST a webhook to Autoplay within a few seconds of the click occurring. The payload will contain the clicked element text, the page URL, and the timestamp.
***
## What the payload looks like
Here is an example of what Autoplay receives for each click:
```json theme={null}
{
"target_text": "Export CSV",
"element_name": "Export button",
"page_url": "https://app.example.com/reports",
"timestamp": "2025-10-21T14:17:34.073Z",
"timestamp_unix": 1761056254,
"session_replay_url": "https://app.fullstory.com/ui/YOURORG/client-session/abc123?ts=1761056254000",
"user_id": "user-462718483",
"user_email": "jane@example.com",
"session_id": "3350978756809951428%3A8226444501427639735"
}
```
***
## Verifying clicks are flowing
To confirm end-to-end after saving the Stream:
1. Open your product in a browser and click around on several elements
2. In FullStory, go to **Settings β Anywhere β Activation** and open the `autoplay-every-click` Stream β you should see recent activity in the event log
3. Check your Autoplay event log to confirm payloads are arriving with the correct `target_text` and `page_url` values
> **If `target_text` is empty for some clicks:** This means the user clicked an element with no visible text β for example, an icon button or an image. In these cases fall back to `element_name`, which FullStory derives from accessibility attributes. If both are empty, the element has no accessible label and may be worth fixing in the product regardless.
***
## Rate limits and delivery
FullStory does not rate limit outbound Streams. However, your Autoplay endpoint needs to be sized appropriately. If your FullStory account captures 50 sessions per second and every session has frequent clicks, your endpoint could receive a high volume of requests. FullStory will retry failed requests up to 30 times over 5 hours if your endpoint returns a `5xx` response or times out.
FullStory sends Stream requests from the following IP addresses β whitelist these on your Autoplay endpoint if needed:
* **US region:** `8.35.195.0/29`
* **EU region:** `34.89.210.80/29`
**Latency in disconnected scenarios:** FullStory sends events on a best-effort basis. In poor or intermittent network conditions, events may arrive minutes after the click occurred. Design your Autoplay context endpoint to return an empty/default response gracefully when no recent events exist for a session β do not assume context is always present when the chat widget opens.
***
## References
* FullStory Streams help doc: [https://help.fullstory.com/hc/en-us/articles/360045134554-Streams](https://help.fullstory.com/hc/en-us/articles/360045134554-Streams)
* FullStory Streams developer reference: [https://developer.fullstory.com/anywhere/activation/streams/](https://developer.fullstory.com/anywhere/activation/streams/)
# Inkeep tutorial
Source: https://developers.autoplay.ai/recipes/inkeep/index
Give Inkeep's AI chat live awareness of what users are doing β so it can explain blockers, surface missing steps, and guide the next action in context.
Without context, your customer support chat has to ask what the user was doing and what went wrong. With Autoplay wired in, Inkeep already knows β and can explain the real blocker, point to the right next step, and link directly to the relevant workflow instead of giving a generic answer.
This tutorial shows you how to build that flow using the Inkeep agents framework and `InkeepEmbeddedChat` from `@inkeep/agents-ui`. Your events, conversation history, and LLM keys never leave your infrastructure.
**Who this is for:** Teams using or evaluating Inkeep who want chat to react to what users actually do β not just answer generic questions. Assumes comfort with Python, TypeScript/React, and a small FastAPI service.
**Stack:** Inkeep agents framework (Docker), `@inkeep/agents-ui`, Next.js 14, Python 3.10+, FastAPI, `uv`, and Anthropic or OpenAI. [https://developers.autoplay.ai/recipes/inkeep](https://developers.autoplay.ai/recipes/inkeep)
## β¨ Final result
```text theme={null}
Admin: why can't I unblock this vendor?
Bot: Vendors may be blocked for several reasons, including policy
violations, expired approvals, or missing compliance information.
Check the vendor details page for more information.
(Generic reply β the bot doesn't know which vendor the admin is viewing
or that the unblock attempt just failed.)
```
```text theme={null}
Admin: why can't I unblock this vendor?
Bot: It looks like you just tried to [complete action] on [item],
but it's blocked because [specific reason]. To move forward,
[next concrete step]. Here's where to do that: [link].
(Same question β but the bot already knows what the user was
on, what they attempted, and exactly what's in the way.)
```
**Video walkthrough** β [Open on Loom](https://www.loom.com/share/1bfd0fd1f326484f85c11fc381f58b69) if the player does not load.
***
## π Prerequisites
Before you start, you need:
* **Node.js 18+** and **Python 3.10+** on the host.
* **`uv`** β the Python package manager used in this tutorial.
```bash theme={null}
curl -LsSf https://astral.sh/uv/install.sh | sh
```
* **`pnpm`** β the Inkeep agents monorepo uses pnpm workspaces.
```bash theme={null}
npm i -g pnpm
```
* **Docker Desktop** β for running Inkeep's backing services (PostgreSQL, Doltgres, SpiceDB).
* **An Anthropic or OpenAI API key** β the Inkeep agents framework calls your LLM directly; no Inkeep cloud key required.
That's it. The tutorial covers every code file step by step β copy-paste runnable.
The Inkeep CDN widget (`@inkeep/cxkit-js`) requires a paid Inkeep cloud API key. This tutorial uses the open-source **Inkeep agents framework** (`@inkeep/agents-ui`) which you self-host with your own LLM key β no per-seat or per-call fee to Inkeep for the chat itself.
***
## Architecture
```text theme={null}
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β VendorOps (Next.js, :3000) β
β β
β ββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β Vendor profile β β
β β Status: blocked β β
β β Missing docs: W-9, certificate of insurance β β
β ββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β sendVendorUnblockAttempt() β
β β POST /demo/actions β
β βΌ β
β βββββββββββββββββββββββββββββββββββββββββββββββββ β
β β Context fetch β β
β β GET /context/{user_id} β β
β βββββββββββββββββββββββββββββββββββββββββββββββββ β
β β β
β βΌ β
β βββββββββββββββββββββββββββββββββββββββββββββββββ β
β β InkeepEmbeddedChat β β
β β β explains missing compliance documents β β
β β β points admin to request-documents flow β β
β βββββββββββββββββββββββββββββββββββββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β POST /demo/actions
β GET /context/{user_id}
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Bridge (FastAPI, :8787) β
β β
β httpx GET β Autoplay connector REST endpoint β
β (stateless pull by product_id + user_id; β
β no stream, no session store) β
β β
β Context: vendor_unblock_attempt + blocked status β
β + missing compliance documents β
β β
β GET /context/{user_id} β pulled + assembled β
β context, on demand β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Inkeep agents framework (:3002) β
β (self-hosted, your LLM key) β
β β
β project: vendor-ops β
β agent: vendor-support β
β sub-agent: compliance-support-worker β
β β
β InkeepEmbeddedChat β AI sub-agent β
β introMessage β context from /context/{user_id} β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
```
Two inputs feed the context layer: the vendor profile reports the admin's action directly to the bridge (`POST /demo/actions`), and the bridge pulls the vendor's blocked status plus missing compliance-document state from the Autoplay connector the moment it's needed. The bridge owns all context logic; Inkeep stays a clean conversational surface.
The `GET /context/{user_id}` endpoint calls the Autoplay connector's REST live-activity endpoint synchronously β a stateless, on-demand pull keyed by the admin's stable `user_id`, not a session β and assembles a human-readable summary of their recent activity and the vendor's compliance state. When the admin opens chat, this summary becomes the `introMessage` passed to `InkeepEmbeddedChat` β so the AI's first message references the blocked vendor and missing documents, not a generic greeting. There's no persistent connection to manage: the bridge asks the connector for "what has this user been doing" exactly once, right when the frontend needs it.
***
## The tutorial
1. [**Step 1 β Connect real-time events**](./step-1-connect-real-time-events) β Build the vendor-management frontend, wire unblock attempts and vendor context into a FastAPI bridge, run the Inkeep agents framework in Docker, and embed `InkeepEmbeddedChat`. At the end of this step you have a working AI chat widget on the vendor profile. \~45 minutes.
2. [**Step 2 β Define proactive triggers**](./step-2-define-proactive-triggers) β Add blocked-vendor detection to the bridge, wire the guidance channel to the frontend, and build the proactive handoff that opens Inkeep chat with the vendor's missing compliance documents pre-loaded. \~45 minutes.
# Connect real-time events
Source: https://developers.autoplay.ai/recipes/inkeep/step-1-connect-real-time-events
Pull a user's live activity from Autoplay on demand, expose it over a simple HTTP endpoint, and wire InkeepEmbeddedChat with a pre-loaded intro message.
## β‘ Add this skill
Add the Autoplay Inkeep skill for an existing Inkeep AI support agent setup.
```bash CLI theme={null}
uvx --from autoplay-sdk autoplay-install-skills --chatbot inkeep
```
View the docs β
Fetch this skill when a customer already uses Inkeep and wants its AI support agent to consume Autoplay live user activity.
```bash cURL theme={null}
curl -s https://developers.autoplay.ai/chatbot-inkeep/SKILL.md
```
View the skill β
This tutorial gets you from zero to a working Inkeep AI chat with grounded context in about 45 minutes.
**What Inkeep gives you in this integration:**
Inkeep's AI chat framework was built for in-product support β grounded in your own product knowledge, not generic LLM replies. The self-hosted [agents framework](https://github.com/inkeep/inkeep-agents) (`@inkeep/agents-ui`) gives you:
* **`introMessage` injection** β the widget's opening message is set before the conversation starts. This is the prop this tutorial uses to deliver Autoplay context β so the first reply says *"I can see you've been on the Connect Data Source step"* instead of *"How can I help?"*
* **`context` passthrough** β structured session data (session ID, user ID, email) flows from the browser into the agent's system prompt on every turn, scoping replies to the right user
* **Agent / sub-agent routing** β a single `appId` dispatches to different LLM workers by intent. Add a billing sub-agent later without touching the widget code
* **Your LLM key, your data** β conversation history, system prompts, and user messages live in a Postgres DB you own; nothing is sent to Inkeep's cloud
The paid **Inkeep CDN widget** (`@inkeep/cxkit-js`) requires an Inkeep cloud API key and calls Inkeep's hosted endpoints. This tutorial uses the open-source **agents framework** (`@inkeep/agents-ui`) which you self-host with your own LLM key. If you already have an Inkeep account, skip Steps 6β7 and point `NEXT_PUBLIC_INKEEP_BASE_URL` at your cloud endpoint instead.
**Before you start, make sure you have:**
* **A PostHog account** (free tier is fine) β for the click-capture step in Step 1. See [PostHog's own docs](https://posthog.com/docs/getting-started/install) if you don't have one yet.
* **Docker and pnpm** β the agents framework runs its backing services (Postgres, Doltgres, SpiceDB) via `docker compose`, and its dev server via `pnpm`.
* **An Anthropic or OpenAI API key** β for the agents framework's LLM calls (Step 6).
***
**What you'll build:**
1. Frontend autocapture with `posthog.identify()` + `posthog.register({email})`.
2. Product onboarding with your PostHog project id for issued Autoplay `product_id`, ingest, MCP, and owner credentials.
3. PostHog destination forwarding events to your Autoplay connector.
4. A small FastAPI bridge that pulls a user's live activity with a plain `httpx` call and exposes it over a `/context/{user_id}` endpoint.
5. The Inkeep agents framework running locally (Docker + pnpm dev server).
6. A project, agent, and sub-agent configured via the Inkeep management API.
7. An `InkeepEmbeddedChat` widget that pre-loads the pulled Autoplay context as its `introMessage`.
8. An end-to-end check where the chat opens knowing exactly what the user was doing.
**Runtime loop:** click in app β event lands in the Autoplay connector via the PostHog webhook β user opens chat β frontend fetches `/context/{user_id}` β bridge pulls that user's live activity over REST, on demand β `InkeepEmbeddedChat` opens with grounded `introMessage`.
**The connector is pull-based, not push-based.** There's no persistent stream for the bridge to hold open and no local event store to keep in sync β the bridge asks "what has this user been doing?" once, synchronously, exactly when the frontend needs an `introMessage`. This is the same pattern used in the [Plain tutorial](/recipes/plain-tutorial/step-1-connect-real-time-events) and [Intercom Fin tutorial](/recipes/intercom-tutorial/step-1-connect-real-time-events) β no listener process required.
## πͺ 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",
});
// 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.
***
## π Step 2 β Register your product with Autoplay
Run a one-time script to create your ingest + MCP credentials.
```bash theme={null}
mkdir -p ~/nexus-cloud/bridge && cd ~/nexus-cloud/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_POSTHOG_PROJECT_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 the registration values β save them:
* `product_id` β the issued Autoplay id, e.g. `prod_wQ7r8kF9...`.
* `provider` β `posthog`.
* `provider_project_id` β your PostHog project id.
* `ingest_url` β PostHog will POST events here (e.g. `https://connector.autoplay.ai/ingest/prod_wQ7r8kF9...`).
* `ingest_secret` β `X-PostHog-Secret` header value for the destination.
* `mcp_url` β always `https://mcp.autoplay.ai/mcp` (informational β this integration 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. Named after the connector's primary MCP interface, but this integration only ever uses it for plain REST calls.
* `owner_token` β shown only on first registration; save it securely for future re-registration or rotation.
**`contact_email` is required.** It is stored on the connector product row so Autoplay can reach you. Re-registering the same provider/project pair returns 409 unless you pass `owner_token=""`, in which case `ingest_secret` rotates β update the PostHog destination in 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. **Headers:** remove the default `Content-Type` row (the Hog code below sets headers itself).
5. Click **Edit source** and paste this script (replace `` and ``).
```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}')
}
```
6. Click **Test function** β expect status 200 in under 200 ms.
7. **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. It assembles user context and serves it over a simple HTTP endpoint β Inkeep handles the LLM call.
You already created `~/nexus-cloud/bridge/` in Step 2. Add the remaining dependencies:
```bash theme={null}
cd ~/nexus-cloud/bridge
uv add python-dotenv fastapi 'uvicorn[standard]' httpx
```
**Why no `/reply` endpoint, and no LLM key in this bridge?** With Inkeep the LLM call happens inside the Inkeep agents framework β your bridge only needs to pull and return the context string, it never talks to an LLM itself. That's also why the old session-summarizer's `OPENAI_API_KEY` is gone: there's no rolling summary to generate anymore, just a bounded window of recent actions the connector already returns. Your LLM credentials live in exactly one place β the agents framework `.env` from Step 6.
Create `bridge/.env` with three of the six credentials returned by `onboard_product`. Map them as follows:
| `onboard_product` field | `.env` variable |
| ------------------------------------ | ----------------------------------------------- |
| `mcp_url` (origin only, drop `/mcp`) | `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=
PRODUCT_ID=YOUR_AUTOPLAY_PRODUCT_ID
# Optional tuning:
ACTIVITY_LIMIT=30
```
***
## π Step 5 β Wire the context endpoint
Create `bridge/copilot_server.py`. There's no SDK pipeline to compose anymore β the bridge makes one `httpx` call to the Autoplay connector's REST live-activity endpoint, synchronously, the moment the frontend asks for context. No listener process, no local event store, no reconnect logic.
### 5a. Imports and config
```python theme={null}
import logging, os
import httpx
from dotenv import load_dotenv
from fastapi import FastAPI
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"]
ACTIVITY_LIMIT = int(os.environ.get("ACTIVITY_LIMIT", "30"))
```
### 5b. The FastAPI app
No `lifespan` hook is needed β there's no client to start or stop when the server boots. The app object is just a plain `FastAPI()`.
```python theme={null}
app = FastAPI(title="Autoplay Γ Inkeep bridge")
```
### 5c. A helper to pull and format live activity
This is the only piece of "pipeline" left: one function that calls the connector and turns the `actions` array into a readable block of text. There's no summarizer step β the connector already returns a bounded, recent window, so raw actions are passed straight through.
```python theme={null}
async def pull_live_activity(user_id: str) -> str:
"""Pull a user's recent activity from the Autoplay connector, synchronously.
Called on demand β once per /context/{user_id} request β not on a timer
and not from a background listener. A 5s timeout keeps a slow connector
response from blocking the widget from opening.
"""
url = f"{CONNECTOR_URL}/users/{PRODUCT_ID}/{user_id}/live-activity"
try:
async with httpx.AsyncClient(timeout=5) as client:
resp = await client.get(
url,
params={"limit": ACTIVITY_LIMIT},
headers={"Authorization": f"Bearer {MCP_KEY}"},
)
except httpx.HTTPError as exc:
# Covers timeouts as well as connect/DNS/TLS failures β any of these
# should degrade to no context, not a 500 on /context/{user_id}.
log.warning("live-activity request failed for user=%s: %s", user_id, exc)
return ""
if resp.status_code == 401:
log.error("live-activity 401 β check MCP_KEY")
return ""
if resp.status_code != 200:
log.warning("live-activity returned %s for user=%s", resp.status_code, user_id)
return ""
actions = resp.json().get("actions", [])
lines = [
f"{a['title']} β {a['description']} ({a['canonical_url']})"
for a in actions
]
return "\n".join(lines)
```
`actions` comes back **oldest β newest**, so the lines above read as a timeline of what the user just did, in order β the same ordering guarantee the old streamed pipeline gave you, just resolved fresh on every call instead of maintained incrementally.
### 5d. The `/context/{user_id}` endpoint and health check
This bridge does not call the LLM for chat replies β Inkeep handles that. It exposes a single read endpoint keyed by the stable `user_id` (the same id `posthog.identify()` set) β the frontend fetches it before opening the chat widget to build the `introMessage`. There's no `session_id` anywhere in this API.
```python theme={null}
@app.get("/healthz")
async def healthz():
return {"status": "ok"}
@app.get("/context/{user_id}")
async def get_context(user_id: str):
"""Return the pulled Autoplay context for a user.
The frontend calls this before mounting InkeepEmbeddedChat so it can
pass the activity summary as introMessage. Returns has_activity=False
when the user has no recent activity yet β the widget falls back to its
default greeting in that case.
"""
text = await pull_live_activity(user_id)
return {
"context": text,
"has_activity": bool(text.strip()),
"user_id": user_id,
}
```
### Start the bridge
```bash theme={null}
cd ~/nexus-cloud/bridge
uv run uvicorn copilot_server:app --host 0.0.0.0 --port 8787
```
You should see plain uvicorn startup logs β there's no stream to connect, so no "listening" or "connected" line to wait for:
```
INFO: Uvicorn running on http://0.0.0.0:8787 (Press CTRL+C to quit)
INFO: Application startup complete.
```
Smoke-test:
```bash theme={null}
curl http://localhost:8787/healthz
# {"status":"ok"}
```
Click around in your app for \~30 seconds, then check the context endpoint:
```bash theme={null}
curl "http://localhost:8787/context/YOUR_USER_ID"
# {"context":"Page Load: Connect Data Source β User landed on the onboarding page (https://.../onboarding)\nClick Test Connection β User clicked the Test Connection button (...)","has_activity":true,"user_id":"..."}
```
`YOUR_USER_ID` is the same id you passed to `posthog.identify(...)` in Step 1. That confirms events are flowing through PostHog into the connector, and that the bridge's REST pull is working.
***
## π€ Step 6 β Run the Inkeep agents framework
Inkeep is an open-source AI agent framework (ELv2 license) that you self-host with your own LLM key. The agents framework ships as a pnpm monorepo.
```bash theme={null}
git clone https://github.com/inkeep/inkeep-agents ~/inkeep-agents
cd ~/inkeep-agents
pnpm install
```
Start the backing services (PostgreSQL on :5433, Doltgres on :5435, SpiceDB on :50051):
```bash theme={null}
docker compose up -d
```
Copy the sample env:
```bash theme={null}
cp .env.example .env
```
Open `.env` and set at minimum:
```ini theme={null}
ANTHROPIC_API_KEY=sk-ant-api03-...
# β or β
# OPENAI_API_KEY=sk-...
INKEEP_AGENTS_MANAGE_DATABASE_URL=postgresql://appuser:password@localhost:5435/inkeep_agents
INKEEP_AGENTS_MANAGE_API_BYPASS_SECRET=test-bypass-secret-for-ci
```
`INKEEP_POW_HMAC_SECRET` controls browser proof-of-work (ALTCHA). **Comment it out for local development.** If set, the browser widget must solve a cryptographic challenge before it can open a conversation β this causes a 400 error during testing.
Start the agents API on port 3002:
```bash theme={null}
pnpm --filter agents-api dev
```
Health check:
```bash theme={null}
curl http://localhost:3002/health
# {"status":"ok"}
```
***
## βοΈ Step 7 β Create a project, agent, and sub-agent
The Inkeep agents framework uses a two-layer model: an **agent** is a named entry point with a routing prompt, a **sub-agent** is the LLM worker that actually calls the model. Both must exist before `InkeepEmbeddedChat` can start a conversation.
All calls below use the bypass auth header (`Authorization: Bearer test-bypass-secret-for-ci`), which sets `userId='system'` and skips permission checks β suitable for local setup only.
**Create the project:**
```bash theme={null}
curl -s -X POST http://localhost:3002/manage/tenants/default/projects \
-H "Authorization: Bearer test-bypass-secret-for-ci" \
-H "Content-Type: application/json" \
-d '{
"id": "nexus-cloud",
"name": "Nexus Cloud",
"models": {"base": {"model": "anthropic/claude-sonnet-4-6"}}
}'
```
**Create the agent:**
```bash theme={null}
curl -s -X POST http://localhost:3002/manage/tenants/default/projects/nexus-cloud/agents \
-H "Authorization: Bearer test-bypass-secret-for-ci" \
-H "Content-Type: application/json" \
-d '{
"id": "onboarding-support",
"name": "Onboarding Support Agent",
"prompt": "You are a helpful onboarding assistant for Nexus Cloud.\n\nNexus Cloud onboarding has 5 steps:\n1. Connect Data Source β paste your API endpoint URL and API key, then click Test Connection.\n2. Invite Your Team β add teammate email addresses and choose their roles.\n3. Configure Workspace β set your workspace name, timezone, and branding.\n4. Set Up Alerts β configure thresholds and notification channels (email, Slack).\n5. Run First Sync β click Run Sync to verify everything is connected.\n\n## If a Current User Activity block is present\nUse it to give specific, context-aware answers. Pick up from where the user is β do not restart the flow from step 1 if they are already on step 4.\n\n## Answering questions\n- Be specific about which field or button to use.\n- Use numbered steps when explaining a flow.\n- If the user is stuck on a step, suggest the most common fix first.\n- Keep replies concise β under 120 words unless the question is complex."
}'
```
**Create the sub-agent:**
```bash theme={null}
curl -s -X POST \
http://localhost:3002/manage/tenants/default/projects/nexus-cloud/agents/onboarding-support/sub-agents \
-H "Authorization: Bearer test-bypass-secret-for-ci" \
-H "Content-Type: application/json" \
-d '{
"id": "onboarding-worker",
"name": "Onboarding Worker",
"models": {"base": {"model": "anthropic/claude-sonnet-4-6"}},
"prompt": "You are a helpful onboarding assistant for Nexus Cloud.\n\nNexus Cloud onboarding has 5 steps:\n1. Connect Data Source β paste your API endpoint URL and API key, then click Test Connection.\n2. Invite Your Team β add teammate email addresses and choose their roles.\n3. Configure Workspace β set your workspace name, timezone, and branding.\n4. Set Up Alerts β configure thresholds and notification channels (email, Slack).\n5. Run First Sync β click Run Sync to verify everything is connected.\n\n## If a Current User Activity block is present\nUse it to give specific, context-aware answers. Pick up from where the user is β do not restart the flow from step 1 if they are already on step 4.\n\n## Answering questions\n- Be specific about which field or button to use.\n- Use numbered steps when explaining a flow.\n- If the user is stuck on a step, suggest the most common fix first.\n- Keep replies concise β under 120 words unless the question is complex."
}'
```
**Set the default sub-agent:**
```bash theme={null}
curl -s -X PATCH \
http://localhost:3000/manage/tenants/default/projects/nexus-cloud/agents/onboarding-support \
-H "Authorization: Bearer test-bypass-secret-for-ci" \
-H "Content-Type: application/json" \
-d '{"defaultSubAgentId": "onboarding-worker"}'
```
**Enable anonymous sessions** so the browser widget can authenticate without a user login:
```bash theme={null}
psql postgresql://appuser:password@localhost:5433/inkeep_agents -c "
UPDATE apps SET
default_agent_id = 'onboarding-support',
project_id = 'nexus-cloud',
tenant_id = 'default',
config = jsonb_set(jsonb_set(config, '{webClient,allowAnonymous}', 'true'), '{webClient,allowedDomains}', '[\"localhost\",\"127.0.0.1\"]')
WHERE id = 'app_playground';
"
```
**Verify the widget can get a session token:**
```bash theme={null}
curl -s -X POST http://localhost:3002/run/auth/apps/app_playground/anonymous-session \
-H "Content-Type: application/json" \
-H "Origin: http://localhost:3000" \
-d '{}'
# {"token":"eyJhbGci..."}
```
A JWT in the response confirms the widget can authenticate.
**Why both an agent and a sub-agent?** The top-level agent is the named entry point registered in your app config. The sub-agent is the conversational worker that actually calls the LLM. This separation lets you route different intents to different sub-agents later β for example, a billing sub-agent and a product sub-agent under the same top-level agent β without changing the widget's `appId`.
***
## π¬ Step 8 β Wire InkeepEmbeddedChat into your app
### 8a. Configure Next.js
`@inkeep/agents-ui` ships ESM-only. Tell Next.js to transpile it:
```ts theme={null}
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
transpilePackages: ["@inkeep/agents-ui"],
};
export default nextConfig;
```
Install the package:
```bash theme={null}
cd ~/nexus-cloud/frontend
npm install @inkeep/agents-ui
```
### 8b. Environment variables
Create `frontend/.env.local`:
```ini theme={null}
NEXT_PUBLIC_INKEEP_BASE_URL=http://localhost:3002
NEXT_PUBLIC_INKEEP_APP_ID=app_playground
NEXT_PUBLIC_BRIDGE_URL=http://localhost:8787
```
### 8c. The `InkeepWidget` component
Create `frontend/components/InkeepWidget.tsx`.
The key pattern here is:
1. On mount (and whenever `contextKey` changes), fetch `/context/{user_id}` from the bridge.
2. If `has_activity` is true, build a warm `introMessage` that leads with what the user was doing.
3. Pass `introMessage` to `InkeepEmbeddedChat`.
4. Use `key={contextKey}` to force a full component remount with the new `introMessage` when the proactive trigger fires (Step 2). Without this prop, React reuses the old component instance and the new intro message is silently ignored.
```tsx theme={null}
"use client";
import { useEffect, useState } from "react";
import dynamic from "next/dynamic";
const InkeepEmbeddedChat = dynamic(
() => import("@inkeep/agents-ui").then((m) => m.InkeepEmbeddedChat),
{ ssr: false }
);
const BASE_URL = process.env.NEXT_PUBLIC_INKEEP_BASE_URL ?? "http://localhost:3002";
const APP_ID = process.env.NEXT_PUBLIC_INKEEP_APP_ID ?? "app_playground";
const BRIDGE_URL = process.env.NEXT_PUBLIC_BRIDGE_URL ?? "http://localhost:8787";
type Props = {
userId: string;
contextKey?: string;
};
export function InkeepWidget({ userId, contextKey }: Props) {
const [introMessage, setIntroMessage] = useState(
"Hi! I'm your onboarding assistant. Ask me anything about setting up Nexus Cloud."
);
useEffect(() => {
if (!userId) return;
fetch(`${BRIDGE_URL}/context/${encodeURIComponent(userId)}`)
.then((r) => r.json())
.then((data) => {
if (data.has_activity) {
setIntroMessage(
`I can see you've been working on the onboarding. ${data.context_hint ?? ""} What can I help with?`
);
}
})
.catch(() => {});
}, [userId, contextKey]);
return (
);
}
```
### `InkeepEmbeddedChat` props reference
| Prop | Where it lives | What it does |
| ---------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `baseUrl` | `aiChatSettings` | Your self-hosted agents API (`:3002`). For Inkeep cloud, use your cloud endpoint. |
| `appId` | `aiChatSettings` | Which app config to load from the manage DB. `app_playground` is the default seeded entry. |
| `introMessage` | `aiChatSettings` | The AI's **first message** in every new conversation β the injection point for Autoplay context. |
| `context` | `aiChatSettings` | Key-value pairs forwarded to the agent on every turn. Reference them in the system prompt as `{{context.key}}`. |
| `placeholder` | `aiChatSettings` | Chat input hint text shown before the user types. |
| `onInputMessageChange` | `aiChatSettings` | Callback fired on every keystroke. Used in Step 2 to detect "yes" client-side and trigger the guided tour without waiting for message submission. |
| `key` (React prop) | component root | Forces a full remount. `InkeepEmbeddedChat` is stateful β changing `introMessage` after mount has no effect. Pass a new `key` (e.g. a timestamp) to reset the conversation with fresh state and a new opening message. |
| `primaryBrandColor` | `baseSettings` | Tints the widget chrome to match your product colour. |
**Why `key` and not just updating `introMessage`?** `InkeepEmbeddedChat` manages its own conversation state internally. Once mounted, it ignores `introMessage` prop changes β the conversation has already started. Changing `key` tells React to unmount and remount the component, which creates a fresh anonymous session with the new `introMessage` as the AI's opening line. Step 2 relies on this pattern every time a proactive offer fires.
### 8d. Mount the widget in your page
Add `InkeepWidget` to your onboarding page. Pass the stable identify id (available from `posthog.get_distinct_id()`) as `userId` so the bridge can pull the right user's activity.
```tsx theme={null}
// app/onboarding/page.tsx (simplified β your layout will differ)
"use client";
import { useState, useEffect } from "react";
import posthog from "posthog-js";
import { InkeepWidget } from "@/components/InkeepWidget";
export default function OnboardingPage() {
const [userId, setUserId] = useState("");
const [chatOpen, setChatOpen] = useState(false);
useEffect(() => {
setUserId(posthog.get_distinct_id() ?? "");
}, []);
return (
<>
{/* β¦ your onboarding stepper UI β¦ */}
{/* Chat trigger button */}
{!chatOpen && (
)}
{/* Chat panel */}
{chatOpen && userId && (
)}
>
);
}
```
`posthog.get_distinct_id()` returns the identity `posthog.identify(...)` set on login β the same stable `user_id` that flows through PostHog β Autoplay connector. This is the correct join key. There's no `session_id` in this API anymore; if you use a custom identity call, make sure the id you pass to `InkeepWidget` matches the id you pass to `posthog.identify()`.
***
## β Step 9 β Try it
1. Open your app, click through the onboarding wizard for \~30 seconds β e.g. navigate to **Step 1 (Connect Data Source)**, paste a URL into the API endpoint field, click **Test Connection**.
2. Click **Need help?** to open the chat panel.
3. The widget fetches `/context/{user_id}` β `has_activity` is `true` β and mounts with:
> "I can see you've been working on the onboarding. What can I help with?"
4. Ask **"my test connection keeps failing"** β the agent replies with specific guidance about the Connect Data Source step, grounded in the fact that you just tried it.
The agent should not recite click logs. Activity is a private signal used to warm the `introMessage` and keep the agent's reply focused on where you are in the flow.
If context is missing, check bridge logs and the [troubleshooting matrix](#troubleshooting).
```bash theme={null}
# Quick end-to-end check without opening the browser:
curl "http://localhost:8787/context/YOUR_USER_ID"
# {"context":"Page Load: Connect Data Source β User landed on the onboarding page (...)\nClick Test Connection β ...","has_activity":true,"user_id":"..."}
```
In [Step 2](./step-2-define-proactive-triggers) we'll go further: the bridge will notice when the user opens the Connect Data Source step three times without completing it, and surface a proactive offer β without the user typing anything.
***
## π Troubleshooting
| Symptom | Likely cause |
| ------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `introMessage` is always the default greeting | `has_activity` is false. Check (1) bridge is running, (2) PostHog destination is enabled and POSTing, (3) the `userId` passed to `InkeepWidget` matches the id you passed to `posthog.identify(...)` |
| `/context/{user_id}` times out or takes >5s | The connector call in `pull_live_activity` hit its 5s `httpx` timeout. Check `CONNECTOR_URL` is reachable from the bridge host and that the connector isn't degraded. |
| Bridge logs `live-activity 401 β check MCP_KEY` | `MCP_KEY` in `bridge/.env` doesn't match the `mcp_key` printed by `onboard_product` β double-check you copied the current value. |
| `/context/{user_id}` returns `{"context":"","has_activity":false}` for a real, active user | Events not reaching the connector, or wrong `user_id`. Check PostHog destination logs for failed POSTs; confirm `PRODUCT_ID` in `bridge/.env` matches the issued `product_id` from registration; confirm you're querying the exact id `posthog.identify()` was called with β a device id or anonymous id will read an empty bucket. |
| Widget shows "Failed to fetch anonymous session: 401" | `allowAnonymous` not set in the `apps` table. Re-run the `UPDATE apps SET config = ...` from Step 7. |
| 400 β "Proof-of-work challenge required" | `INKEEP_POW_HMAC_SECRET` is set in `~/inkeep-agents/.env`. Comment it out and restart `agents-api`. |
| "Agent does not have a default sub-agent configured" | `defaultSubAgentId` is null. Re-run the `PATCH` to set `defaultSubAgentId` to `onboarding-worker`. |
| Chat widget renders but never connects | CORS β `allowedDomains` in app config must include `localhost`. Verify with `SELECT config FROM apps WHERE id = 'app_playground';`. |
| `tsconfig` error on `@inkeep/agents-ui` import | Add `transpilePackages: ["@inkeep/agents-ui"]` to `next.config.ts`. |
| `introMessage` doesn't update after proactive trigger fires | The `key` prop on `InkeepEmbeddedChat` is not changing. Pass a new `contextKey` (e.g. incrementing counter) to force remount. This is wired in Step 2. |
| PostHog destination test returns `url: This field is required` | Paste the same `ingest_url` into the form-level URL field too β PostHog requires it even though the Hog source overrides it. |
| `API key is not valid: personal_api_key` | Use `phc_β¦` (Project) key in `posthog.init()`, not `phx_β¦` (Personal). |
***
## π Day-2 operations
```bash theme={null}
# Terminal 1 β your web app
cd ~/nexus-cloud/frontend && npm run dev
# Terminal 2 β bridge
cd ~/nexus-cloud/bridge && uv run uvicorn copilot_server:app --port 8787
# Terminal 3 β Inkeep agents backing services
cd ~/inkeep-agents && docker compose up -d
# Terminal 4 β Inkeep agents API
cd ~/inkeep-agents && pnpm --filter agents-api dev
```
After editing `bridge/copilot_server.py`: re-run `uvicorn` (or start it with `--reload` during development).
After editing the agent system prompt via the API (`curl -s -X PATCH β¦`), the change takes effect immediately β no restart needed; the agents framework loads prompt from the database on each conversation turn.
To inspect the agent config at any time:
```bash theme={null}
curl -s http://localhost:3002/manage/tenants/default/projects/nexus-cloud/agents/onboarding-support \
-H "Authorization: Bearer test-bypass-secret-for-ci" | python3 -m json.tool
```
To reset the Inkeep database (wipes all projects, agents, and conversation history):
```bash theme={null}
cd ~/inkeep-agents && docker compose down -v && docker compose up -d
# Then re-run all the curl commands from Step 7.
```
***
## What you've built
You now have an Inkeep AI chat widget whose opening message is grounded in what the user was actually doing β no generic "How can I help?" when you can see they just failed to connect a data source.
* **Reusable bridge:** the REST pull pattern is identical to every other support AI agent recipe β swap Inkeep for a different widget later without rewriting context logic.
* **Bring-your-own model:** the Inkeep agents framework calls your LLM key directly; no per-seat or per-call fee to Inkeep for the chat itself.
* **Nothing to keep alive:** there's no stream to reconnect, no local event store to keep consistent, no summarizer to tune β the bridge makes one bounded `httpx` call per context request and returns what comes back.
* **Self-hosted:** your events, conversation history, and LLM keys never leave your infrastructure.
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.
# Connect real-time events
Source: https://developers.autoplay.ai/recipes/intercom-tutorial/step-1-connect-real-time-events
Connect Intercom Fin to a user's recent in-app activity via the Autoplay MCP server β one MCP connection, one tool, with Messenger JWT identity verification.
## β‘ Add this skill
Add the Autoplay Intercom Fin skill for an existing Intercom Fin AI support agent setup.
```bash CLI theme={null}
uvx --from autoplay-sdk autoplay-install-skills --chatbot intercom
```
View the docs β
Fetch this skill when a customer already uses Intercom Fin and wants its AI support agent to consume Autoplay live user activity.
```bash cURL theme={null}
curl -s https://developers.autoplay.ai/chatbot-intercom/SKILL.md
```
View the skill β
**Intercom Fin** pulls a user's recent in-app activity on demand via the **Autoplay MCP server** β Fin calls it the moment it needs context to answer. One MCP connection, one tool (`Get Live User Activity`), and a verified identity so Fin asks for the right user.
This guide assumes you **already have Intercom Fin set up** on a **US-hosted** workspace (Intercom's MCP connectors require it). If you don't have Fin set up yet, see [Intercom's own Fin AI Agent docs](https://www.intercom.com/help/en/collections/6485365-fin-ai-agent) to set it up first, then come back here.
**What Autoplay needs from your Intercom setup:**
* **`mcp_key`** β printed by your own `onboard_product` call (see [Quickstart](/quickstart)), not something Intercom issues.
* **Unified Secret** (Settings β Channels β Messenger β Security) β only needed if you haven't wired Messenger JWT identity yet; skip if it's already running.
* **A stable `user_id`** β the same id your activity source (PostHog/Amplitude) identifies the user with.
## π¬ Watch the walkthrough
Prefer to watch first? This short Loom walks through the entire setup end to end β adding the MCP server, the tool, identity, and testing the full loop.
**Prerequisite:** Intercom's MCP connectors require a **US-hosted** Intercom workspace.
## π Add the Autoplay MCP server
In **Intercom:** Settings β **Integrations** β **Data connectors**. Click the **Custom MCP** tile (or **New** β **Custom MCP**) and enter:
* **Name:** `Autoplay live activity`
* **URL:** `https://mcp.autoplay.ai/mcp`
Click **Create / Add MCP Server**. The server appears in your connectors list showing **"(0)"** β connected, but no tools added yet.
## π§° Add the Get Live User Activity tool
Click **+ New** under the **Autoplay live activity** server. In the **Add Autoplay live activity connectors** modal, check **Get Live User Activity** and click **Add connectors**. The tool is added and the count updates to **"(1)"**.
## π Open the tool and edit it
Click the **Get Live User Activity** tool in the list to open it (it opens in **Draft**). Click **Edit** to configure it.
The tool opens with tabs across the top β **API** (the endpoint, its inputs, and authentication) and **Fin** (the prompt that tells Fin *when* to use the tool). Set up the **API** tab first (inputs, then authentication), then the **Fin** tab (the prompt).
## ποΈ Configure the data inputs (on the API tab)
On the **API** tab, the tool has three inputs β **product\_id**, **user\_id**, **limit**. Set each:
* **product\_id** β set **Data source** to **Custom value** and enter **your** product id (the same one from your Autoplay SDK setup). Also set the **Fallback value** to that **same** product id β so the connector always receives it even if the custom value is ever missing.
* **user\_id** β choose **Use an attribute** and select the **User ID** people attribute. This is the verified id from the Messenger JWT (set up under *Verify identity with a Messenger JWT* below) β it must equal the stable user id your activity source identifies the user with (e.g. the `posthog.identify(...)` id, or the Amplitude `user_id`).
* **limit** β optional; leave as **Let Fin decide** (or ignore it).
For **user\_id**, pick the **User ID** attribute β **not Contact ID** and **not email**. *Contact ID* is Intercom's internal `integer` id; email isn't the stable key. Activity is stored under your activity source's stable user id (e.g. the `posthog.identify` id), so anything else reads an empty bucket.
## π Add the authentication token (on the API tab)
Still on the **API** tab, find the **Authentication** section. The tool needs your `mcp_key`, so click **New token** (**Authentication tokens** β **Custom**) and fill in:
* **Type:** Text
* **Token value:** `YOUR_MCP_KEY` β replace with the real `mcp_key` printed by your own `onboard_product` call (see [Quickstart](/quickstart)); don't paste the placeholder text literally.
* **Token prefix:** `Bearer`
* **Key for request header:** `Authorization`
Save, then back in the connector's **Authentication** dropdown, select the token you just created.
The **Token prefix** field is why you do **not** type `Bearer ` into the token value β Intercom prepends the prefix for you. The final header sent is `Authorization: Bearer YOUR_MCP_KEY`. The token's `external_id` must equal your `product_id`, or calls return **403**.
## π¬ Set the Fin prompt (on the Fin tab)
Now switch to the **Fin** tab (next to **API** at the top of the tool). This is where you tell Fin **when** to use the tool β the field that actually drives Fin's decision to call it. (The **API** tab also has a technical description, but the **Fin** tab's prompt is the one that matters here.) Replace the default with a clear, Fin-facing trigger description. Recommended:
```text theme={null}
Use this when a customer references their recent in-app behavior or when understanding their recent actions would help resolve their issue. Specific triggers include:
- Customer asks "what was I just doing?" or "where was I?" or refers to a recent action they took
- Customer mentions encountering an error, bug, or unexpected behavior and you need to see what steps led to it
- Customer is stuck in a flow and needs help figuring out where they are or what to do next
- Customer says "I just clicked something" or "I submitted a form" but is unsure what happened
- You need context about the customer's recent navigation path to troubleshoot or guide them
No input is required from the customer β call this action directly.
This returns a chronological list of the customer's recent in-app activity (oldest to newest), including pages they viewed, buttons they clicked, and forms they submitted.
Do NOT use this when:
- The customer is asking about account details, billing, or subscription information
- The customer is asking about product features or general how-to questions that don't require knowing their recent activity
- The question can be answered without needing to know what the customer recently did in the app
```
The **Fin prompt** is the single biggest factor in whether Fin reliably calls the tool. Make it about **when to use it**, not how it works internally.
## π Verify identity with a Messenger JWT
**What this is, in plain terms:** Fin should only fetch the *logged-in* user's activity, so Intercom needs proof of who that user is. A **Messenger JWT** is a short, signed token your app creates that tells Intercom *"this visitor is user `X`."* You sign it on **your server** (using a secret from Intercom), then hand it to the Intercom **Messenger** in **your frontend** β the same place you already boot Intercom today.
The `user_id` you put in that token **must equal** the id activity is stored under β the stable user id your **activity source** identifies the user with (e.g. the one you pass to `posthog.identify(...)`, or the Amplitude `user_id`). That match is the whole point (see **[Identity](/activity/identity)**).
**How the pieces fit:** your frontend asks *your* server for a token β your server signs it with the Intercom secret β your frontend hands it to Intercom on boot β Intercom trusts the `user_id` and passes it to Fin.
For **logged-in** users, Intercom will **not** give Fin a trusted identity until Messenger JWT verification is set up **and enforced** (step 4). Until then, lookups come back empty.
### 1. Get your Unified Secret β in Intercom
**Settings β Channels β Messenger β Security.** Copy the **secret** used for identity verification.
This secret is **server-side only**. Never ship it to the browser, never put it in your frontend bundle, and never commit it β anyone with it can forge any user's identity. Store it as an env var (e.g. `INTERCOM_IDENTITY_SECRET`).
### 2. Sign the token β on your backend
This runs in **your server code** (where the secret is safe). Add an endpoint your frontend can call to get a token for the currently logged-in user. Node/Express is shown, but any backend works β only the claims (`user_id`, `email`, `exp`) and the HS256 signature matter:
```javascript theme={null}
// BACKEND β runs on your server, where INTERCOM_IDENTITY_SECRET is safe.
import jwt from "jsonwebtoken";
// e.g. an Express route your frontend calls after login:
app.get("/intercom-jwt", requireAuth, (req, res) => {
const token = jwt.sign(
{
user_id: req.user.id, // MUST equal the id you pass to posthog.identify(...)
email: req.user.email,
exp: Math.floor(Date.now() / 1000) + 60 * 60, // expires in 1 hour
},
process.env.INTERCOM_IDENTITY_SECRET, // the Unified Secret from step 1
{ algorithm: "HS256" }
);
res.json({ token });
});
```
### 3. Boot the Messenger with the token β in your frontend
Wherever you **already initialize the Intercom Messenger** (your app's frontend, after the user logs in), fetch the token from the endpoint above and pass it as the **sole** identity source:
```javascript theme={null}
// FRONTEND β the same place you already boot the Intercom Messenger.
const { token } = await fetch("/intercom-jwt").then((r) => r.json());
window.Intercom("boot", {
app_id: "YOUR_INTERCOM_APP_ID",
intercom_user_jwt: token, // the SOLE identity source β do NOT also set user_id/email here
});
```
Test before enforcing: paste a freshly signed token into a JWT decoder (e.g. jwt.io) and confirm the `user_id` claim is **exactly** your activity source's stable user id (e.g. the value you pass to `posthog.identify(...)`). A mismatch here is the #1 cause of "Fin sees no activity."
### 4. Enforce Messenger Security β in Intercom
Back in **Settings β Channels β Messenger β Security**, turn on **enforcement** for the web Messenger. Enforcement is what makes Intercom **trust and forward** the verified identity to Fin β do this **only after** step 3 works and your tokens decode correctly.
Use a **stable user id** for the `user_id` claim β your internal user primary key, the same one your activity source identifies the user with. Do **not** use **email**: emails change, and activity is keyed by the stable id, so an email claim reads the wrong (or empty) bucket.
## π§ͺ Test the connection
On the tool's **Test** tab, set test values: **product\_id** (your product) and **user\_id** (a real user you've identified and browsed as). Run the **live test** β you want a **200** with a populated `actions` array.
* **401** β the auth token isn't selected/configured β revisit *Add the authentication token* above.
* **200 with empty `actions`** β identity is fine but that user has no recent activity yet (browse as them first).
## π Set live
Click **Set live**. The tool flips from **Draft** to **Live** and Fin will call it in real conversations.
On the **Fin** tab, ensure **"Enable Fin to use this connector directly"** is checked so Fin triggers it automatically based on the description.
## β Test the full loop
1. **Log in** to your app as a test user (fires your activity source's identify β e.g. `posthog.identify(...)` β with that user's stable id).
2. **Click around** β visit a couple of pages, click a button, submit a form.
3. **Open the Messenger** as that same logged-in user (the JWT boots with the matching `user_id`).
4. **Ask Fin:** *"What have I been doing in the app recently?"*
5. Fin calls **Get Live User Activity** and answers with **what you actually just did**.
**"No recent activity" = identity mismatch.** Confirm the **same** value in all three:
1. the id your activity source identifies the user with (e.g. `posthog.identify`),
2. the `user_id` claim in the Messenger JWT,
3. the **User ID** attribute you bound on the API tab.
If they don't match, activity is stored under one key and fetched with another, and the lookup comes back empty.
Once it's answering correctly, jump into our [Discord](https://discord.gg/jCbR2tQA5) and say hi β we'll confirm Fin is pulling activity cleanly and help you tune the trigger description.
# Landbot tutorial
Source: https://developers.autoplay.ai/recipes/landbot/index
Connect live user data from the Autoplay SDK straight into your Landbot agent for real-time context-aware conversations.
Learn how to connect live user data from the Autoplay SDK straight into your Landbot agent. This guide walks through wiring a lightweight backend route that pulls a user's live activity **on demand** from the Autoplay connector the moment Landbot needs context to answer β no background listener, no local event store to manage.
## β¨ Final result
***
## π Prerequisites
Complete the [Quickstart](/quickstart). You should have:
* **Your activity source set up** β PostHog (or Amplitude) in the browser, with `identify` (or `setUserId`) setting a stable `user_id` for each logged-in user
* **A registered product** β your `product_id`, `mcp_url`, and `mcp_key` printed by `onboard_product` in the Quickstart
* **A [Landbot](https://landbot.io) account**
***
## The building blocks
Follow the steps in order β each page stands alone so you can ship incrementally.
1. **[Connect real-time events](./step-1-connect-real-time-events)** β Set up the Landbot workflow, wire a lightweight backend server, and embed the support AI agent in your frontend app.
2. **[Define proactive triggers](./step-2-define-proactive-triggers)** β Proactively message users in Landbot based on what they're doing in your product. *(Coming soon)*
# Connect real-time events
Source: https://developers.autoplay.ai/recipes/landbot/step-1-connect-real-time-events
Set up the Landbot workflow, wire a lightweight backend server, and embed the support AI agent in your frontend app.
## β‘ Add this skill
Add the Autoplay Landbot skill for an existing Landbot AI support agent setup.
```bash CLI theme={null}
uvx --from autoplay-sdk autoplay-install-skills --chatbot landbot
```
View the docs β
Fetch this skill when a customer already uses Landbot and wants its AI support agent to consume Autoplay live user activity.
```bash cURL theme={null}
curl -s https://developers.autoplay.ai/chatbot-landbot/SKILL.md
```
View the skill β
This guide has three parts:
1. **Setup Landbot Workflow** β Build the bot flow with a Webhook node and AI Agent.
2. **Setup Backend Server** β Run a small FastAPI route that pulls a user's live activity from Autoplay on demand, the moment Landbot's webhook fires.
3. **Add support AI agent to Frontend** β Embed the Landbot widget in your app.
***
## π€ Part 1 β Setup Landbot Workflow
**Plan requirements:** This tutorial uses two features that require a paid Landbot plan:
* **Webhook block** β requires the Pro plan (approx. β¬80β105/month). Not available on the free Sandbox or Starter plans.
* **AI Agent block** β requires at minimum the Starter plan (includes 100 AI chats/month).
If you are on the free Sandbox plan, upgrade before building this flow or you will hit a feature lock during testing.
The Landbot flow has five nodes wired together:
**1. Starting Point** β This is where the bot wakes up. Every conversation begins here β think of it as the entry door.
**2. Ask a Question** β The bot presents a question to the user and waits for their input. Whatever the user types is passed along to the next blocks. This is how the bot collects the user's actual message or query.
**3. Webhook** β This runs in parallel with the "Ask a Question" block. (Note: 'https requests' is the subtitle Landbot automatically assigns to this block type β it is not something you configure.) It makes an HTTP request to your external server β this is where your FastAPI endpoint gets called. That route, in turn, pulls the user's live activity from the Autoplay connector **on demand**, at the moment the request comes in β there's no background process feeding it. The response (your real-time context) gets stored in a variable like `@context`. The red arrow indicates an error/fallback path in case the request fails. You must connect this output to a fallback block β for example, a message block that says "Sorry, I couldn't load your activity right now." If the red output is left unconnected, the flow will break silently when the webhook fails.
**4. AI Agent** β This is the brain of the bot. It receives both the user's question (from the Ask a Question block) and the live context fetched by the Webhook block, then generates an intelligent response. You configure its system prompt here to reference `@context` so it answers based on your real-time data.
**5. End of Conversation** β Once the AI Agent has responded, the flow terminates here. The conversation is closed and marked as complete in Landbot's dashboard.
### Webhook Node Setup
Webhook URL format:
```
https://[YOUR_SERVER_URL]/context?secret=[YOUR_WEBHOOK_SECRET]&user_id=[@user_id]
```
`[@user_id]` is a Landbot variable you set earlier in the flow β see **Identity** below for how to capture it before this block fires.
**Timeout limit:** Landbot's webhook block will time out after 60 seconds. If your server is slow to start, the request will fail silently. Make sure your FastAPI server is fully running and your Ngrok tunnel is active before testing the flow.
**HTTPS required:** Landbot's webhook block only accepts `https://` URLs. Plain `http://` URLs will return an error. Always use your Ngrok HTTPS URL (e.g. `https://xxxx.ngrok-free.app`), never the local `http://localhost:5000` address.
### π Identity β capture the user's id
The live-activity read is keyed by `product_id` **and** `user_id` β there's no session or stream to subscribe to, so every Webhook call must carry a stable user id, not just the shared `WEBHOOK_SECRET`.
Capture it in the flow **before** the Webhook block fires and store it as a Landbot variable (e.g. `@user_id`). A few ways to do that, depending on how your bot is deployed:
* Add an **Ask a Question** block earlier in the flow that asks for an email or account id, and save the answer as `@user_id`.
* If your bot only ever appears behind a login, use an existing Landbot system variable that already carries the logged-in identity (e.g. `@customer_id` or `@email`), if your integration sets one.
* If the widget is embedded in a logged-in app, pass the id in via Landbot's URL params when you initialize the widget, and reference it the same way in the flow.
Then interpolate that variable into the Webhook URL exactly like Landbot already does for `[YOUR_WEBHOOK_SECRET]`:
```
https://[YOUR_SERVER_URL]/context?secret=[YOUR_WEBHOOK_SECRET]&user_id=[@user_id]
```
The value you capture in `@user_id` **must exactly equal** the id your activity source uses:
```javascript theme={null}
// These two must be identical:
posthog.identify(currentUser.id); // activity source, set on login in your app
// @user_id in the Landbot flow must resolve to this same currentUser.id β
// via an Ask a Question block, a Landbot identity variable, or a URL param
// passed in when you initialize the widget for a logged-in user.
```
```javascript theme={null}
// These two must be identical:
amplitude.setUserId(currentUser.id); // activity source, set on login in your app
// @user_id in the Landbot flow must resolve to this same currentUser.id.
```
**How the pieces fit:** your app identifies the user in PostHog/Amplitude β Autoplay stores activity under that id β your Landbot flow captures the same id into `@user_id` β the Webhook block sends it as a query param β your server's `/context` route passes it straight through to the live-activity read β the buckets match.
If `@user_id` is empty or doesn't match the id your activity source uses, the live-activity read comes back with an empty `actions` array β `@context` will read "no recent activity" even for an active user.
### Map the Webhook Response to a Variable
After configuring your webhook URL and method, you must explicitly map the API response to a Landbot variable β otherwise `@context` will be empty when the AI Agent tries to use it. This step is required.
1. Click **Test the request** inside the Webhook block to fire a live request to your server. You should see a 200 response with a `context` field in the response panel on the right.
2. Click on the `context` value in the response panel. A tooltip will appear saying "Save this as a Field".
3. In the "Save Responses as Fields" section that appears, create a new variable named `@context` (type: Text).
4. Confirm the mapping. The `@context` variable is now populated with the live data from your server each time a user sends a message.
Do not skip this step. Without the field mapping, `@context` will always be empty and the AI Agent will have no real-time data to work with.
### Agent Setup
**Agent Instructions**
```text theme={null}
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" record
You may receive a special record titled "Current User Activity" in the
retrieved context. This shows what THIS user has been doing on the
platform in the last 2 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.
@context
When this record 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.
## β How to answer questions
- **Be specific**: reference actual button names, tab labels, and
menu items from the knowledge base.
- **Use numbered steps**: when explaining how to do something,
always use a numbered list.
- **Keep it simple**: avoid technical jargon. Explain as if the
user has never used the platform before.
- **Be encouraging**: use phrases like "Great question!" or
"That's easy to do" to make users feel comfortable.
- **Offer next steps**: after answering, suggest what they might
want to do next.
- **Admit when you don't know**: if the knowledge base doesn't
have the answer, say so honestly.
## π Language
Respond in the same language the user writes in.
## β Examples of good responses
User is on the Dashboard, 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. Choose the type, template, or options that match what you're creating
4. Fill in the required details and click 'Create'
Would you like me to explain what each field means?"
User is on the Invoice page, asks "Where are settings?":
"The settings aren't on this page β you can find them by clicking
on your profile icon in the top right corner, then selecting
'Settings' from the dropdown menu."
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?"
```
To configure the agent instructions in Landbot:
1. **Select the agent** β open the AI Agent node in your flow.
2. **Edit the Agent AI Instructions** β paste the prompt above into the instructions field.
3. **Review the `@context` variable** β confirm it is injected where `@context` appears in the prompt.
4. **Publish the flow** β click the **Publish** button (top right of the flow builder) to make your changes live. Note that **Save** only saves a draft β you must click **Publish** for the bot to update. After publishing, confirm that your bot is assigned to a web channel so the embed snippet will work.
***
## π Part 2 β Setup Backend Server
There's no background process to run anymore β the connector is **pull-based**. Your server calls the Autoplay live-activity endpoint synchronously, the moment Landbot's webhook fires, and returns the formatted result straight back in the response.
### Prerequisites
* Python 3.10+
* A [Landbot](https://landbot.io) account
* `uvicorn` for serving the FastAPI app
* (Optional) [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) or [Ngrok](https://ngrok.com/docs/guides/share-localhost/quickstart) for local development
Install dependencies:
```bash theme={null}
pip install fastapi httpx uvicorn python-dotenv
```
### Project Structure
```
|- .env # store your env var secrets
|- server.py # server that responds to Landbot webhook requests
```
### Setup your secrets in a `.env` file
```bash theme={null}
CONNECTOR_URL="https://mcp.autoplay.ai"
MCP_KEY="YOUR_MCP_KEY"
PRODUCT_ID="YOUR_PRODUCT_ID"
WEBHOOK_SECRET="YOUR_WEBHOOK_SECRET"
```
* `CONNECTOR_URL` β host that serves the live-activity read API, `https://mcp.autoplay.ai` (the origin of your `mcp_url`, without the `/mcp` path)
* `MCP_KEY` β Bearer token for the live-activity API, the `mcp_key` printed by `onboard_product` in the Quickstart
* `PRODUCT_ID` β your Autoplay product id, scopes the read to your product
`WEBHOOK_SECRET` is your own secret value β it can be any string, e.g. `"DKFGEO293KDDA92"`. Use the same value in the webhook URL query param. It's unrelated to `MCP_KEY` β this one only authenticates Landbot's calls to your server.
### Setup the Webhook Server (`server.py`)
On every incoming Landbot webhook request, this route calls the Autoplay live-activity endpoint for the given `user_id` and formats the result for the AI Agent's `@context` variable β no local file, no cache, no background listener.
```python theme={null}
import os
from fastapi import FastAPI, Header, Query, HTTPException
import httpx
from dotenv import load_dotenv
load_dotenv()
app = FastAPI()
CONNECTOR_URL = os.getenv("CONNECTOR_URL", "https://mcp.autoplay.ai")
MCP_KEY = os.getenv("MCP_KEY", "")
PRODUCT_ID = os.getenv("PRODUCT_ID", "")
API_SECRET = os.getenv("WEBHOOK_SECRET", "")
PORT = int(os.getenv("PORT", 5000))
def format_actions(actions: list[dict]) -> str:
if not actions:
return "No events recorded yet."
lines = []
for i, action in enumerate(actions):
lines.append(f"[{i}] Action: {action.get('title', action.get('type', ''))}")
lines.append(f" Details: {action.get('description', '')}")
lines.append(f" Page: {action.get('canonical_url', '')}")
return "\n".join(lines)
@app.post("/context")
def generate_event_context(
user_id: str = Query(...),
secret: str | None = Query(default=None),
x_secret_key: str | None = Header(default=None),
):
token = x_secret_key or secret
if API_SECRET and token != API_SECRET:
raise HTTPException(status_code=401, detail="Unauthorized")
url = f"{CONNECTOR_URL}/users/{PRODUCT_ID}/{user_id}/live-activity"
try:
resp = httpx.get(
url,
params={"limit": 10},
headers={"Authorization": f"Bearer {MCP_KEY}"},
timeout=10.0,
)
resp.raise_for_status()
actions = resp.json().get("actions", [])
except httpx.HTTPError as e:
print(f"Error fetching live activity for user_id={user_id}: {e}")
actions = []
context = format_actions(actions)
return {
"context": context,
"line_count": len(context.splitlines()),
}
@app.get("/health")
def health():
return {"status": "ok"}
if __name__ == "__main__":
import uvicorn
print(f"Starting context server on port {PORT}...")
uvicorn.run("server:app", host="0.0.0.0", port=PORT)
```
`user_id` comes straight from the `[@user_id]` query param wired up in **Identity** above β the route passes it through unchanged to the live-activity read, so whatever Landbot sends is exactly what scopes the lookup.
### Run the server and expose it to the internet
**Start the webhook server:**
```bash theme={null}
uvicorn server:app --reload --port 5000
```
**Expose it publicly with Ngrok:**
```bash theme={null}
ngrok http 5000
```
Copy the Ngrok HTTPS URL and use it as `[YOUR_SERVER_URL]` in the Landbot webhook URL.
***
## π Part 3 β Add support AI agent to your Frontend App
In Landbot, click **Share** on your bot, then click the **body** button to copy the HTML embed code.
Paste the HTML snippet inside the `` of your frontend app's `index.html`:
```html theme={null}
```
The exact snippet β including your real `configUrl` β is generated by Landbot in the Share panel. Always copy it directly from there rather than using the example above. The loader `
```
If your site uses a **Content Security Policy**, allow `chat.onmaven.app` in your `script-src`, `connect-src`, and `frame-src` directives.
At this point the widget loads β but Maven doesn't yet know **who** the user is. That's Part D.
***
## π Part D β Pass a verified user identity
Maven only pulls the *right* user's activity if it sends the correct `user_id` to the tool. The secure way is **signed user data**: your backend cryptographically signs the logged-in user's identity, so it can't be forged.
### 1. Configure the keys β in Maven
In the Chat app β **Settings** β **Security**, set:
* **JWT Public Key** β the public half of a signing keypair you generate (Maven uses it to verify the signature).
* **Encryption secret** β a shared secret (Maven uses it to decrypt the token).
Generate them once:
```bash theme={null}
# EC P-256 keypair (ES256) β paste the PUBLIC key into "JWT Public Key"
openssl ecparam -genkey -name prime256v1 -noout -out private.pem
openssl ec -in private.pem -pubout -out public.pem
openssl pkcs8 -topk8 -nocrypt -in private.pem -out private_pkcs8.pem # use this on your server
# 32-byte base64url encryption secret β paste into "Encryption secret"
openssl rand 32 | base64 | tr '+/' '-_' | tr -d '='
```
The **private key** and the **encryption secret** are server-side only. Never ship them to the browser or commit them.
### 2. Sign the user's identity β on your backend
Add an endpoint your frontend calls for the logged-in user. It **signs** the user's data (ES256), then **encrypts** the signed token (JWE):
```javascript theme={null}
// BACKEND β runs on your server, where the private key + secret are safe.
import { SignJWT, EncryptJWT, importPKCS8, base64url } from "jose";
export async function getMavenToken(user) {
const privateKey = await importPKCS8(process.env.MAVEN_PRIVATE_KEY_PKCS8, "ES256");
const signed = await new SignJWT({
id: user.id, // β the SAME id your analytics identifies the user with
firstName: user.firstName,
lastName: user.lastName,
email: user.email,
})
.setProtectedHeader({ alg: "ES256" })
.setIssuedAt()
.setExpirationTime("1d")
.sign(privateKey);
return new EncryptJWT({ jwt: signed })
.setProtectedHeader({ alg: "dir", enc: "A128CBC-HS256" })
.encrypt(base64url.decode(process.env.MAVEN_ENCRYPTION_SECRET));
}
```
Set **`id`** to the **same stable id your analytics identifies the user with** (the value you pass to `posthog.identify(...)` / Amplitude `setUserId(...)`). Maven fills the tool's `user_id` argument from this verified identity β so make sure `id` is your canonical user id, not just an email.
### 3. Hand the token to the widget β in your frontend
Fetch the token and pass it as `signedUserData`. Send your constant `product_id` as `unsignedUserData`:
```javascript theme={null}
const { token } = await fetch("/api/maven-token").then((r) => r.json());
Maven.ChatWidget.load({
organizationId: "YOUR_ORGANIZATION_ID",
agentId: "YOUR_AGENT_ID",
signedUserData: token, // verified identity (incl. user_id)
unsignedUserData: { product_id: "YOUR_PRODUCT_ID" },
});
```
Replace `YOUR_ORGANIZATION_ID`, `YOUR_AGENT_ID`, and `YOUR_PRODUCT_ID` with the real values from your Chat app's Instructions tab and your own `onboard_product` call β don't paste the placeholder text literally.
**The single most important rule:** the `user_id` Maven sends **must equal** the id your activity is stored under β the same stable id you pass to `posthog.identify(...)` / Amplitude `setUserId(...)`. If they don't match, lookups come back empty. Pick **one** canonical user identifier and use it everywhere: your analytics `identify()`, the signed `user_id`, and the connector.
***
## β Test it
1. Log in to your app as a user and **click around** a few pages.
2. Wait a few seconds for the events to reach the connector.
3. Open the Maven widget and ask: **"What have I done recently?"**
Maven should answer with the pages and actions that user just took.
Almost always an **identity mismatch** β the `user_id` Maven sent doesn't match the id your activity is stored under. Confirm your analytics `identify()` id, the signed `user_id` field, and the connector's stored id are all the **same** value.
Check **Allowed domains** in the Chat app settings includes your domain, and that your CSP allows `chat.onmaven.app`.
Verify the **MCP URL** and **token** are correct, and that you installed the MCP app **inside an agent**. Reinstall to re-discover tools.
***
Once Maven can pull a user's activity on demand, you're done with Step 1. Next: **[Step 2 β Add proactive layer](/recipes/maven/step-2-define-proactive-triggers)**.
# Step 2 β Add proactive layer
Source: https://developers.autoplay.ai/recipes/maven/step-2-define-proactive-triggers
Use Autoplay.js, a tour provider, and Maven MCP tools to offer and launch guided onboarding tours from Maven.
Maven can call Autoplay MCP tools during a conversation, but **Maven is not proactive on its own**. It does not watch live product events and open a message or tour without another layer.
By this stage, you have already connected the Autoplay MCP server in **[Step 1](/recipes/maven/step-1-connect-real-time-events)**. You do not need to do anything else MCP-wise. Maven already has the context and tools it needs; this step only sets up the proactive prompt and connects the visual guidance layer.
For proactive onboarding with Maven, use:
1. **Autoplay.js** in your app to listen for proactive nudges.
2. **A user tour provider** to show the guided in-app tour.
3. **Maven** to read activity, offer the next step, and launch the tour only after the user says yes.
This guide uses **Appcues** as the concrete example. The same model can work with other tour providers as long as Autoplay.js can dispatch the tour nudge to that provider.
Maven does not provide its own proactive messaging UI. Since Maven can't show the offer by itself, use your user tour provider (or Autoplay's built-in nudge card) to add the box the user actually sees and can accept or dismiss β Maven only handles the agent conversation once they do.
## β Before you start
Finish these first:
* **[Step 1 β Connect real-time events](/recipes/maven/step-1-connect-real-time-events)** so Maven can call Autoplay MCP tools.
* **[Quickstart Step 3 β Connect Autoplay.js](/quickstart#-step-3-β-connect-autoplay-js)** so your app can receive Autoplay nudges.
* A tour provider setup. For Appcues, follow **[Appcues setup](/recipes/appcues/how-to-setup)** so `AP.dispatchNudge(nudge)` can launch Appcues flows.
## π§ What Maven already has from MCP
Because you connected the Autoplay MCP server in Step 1, Maven already has these tools in **Capabilities**. You are not adding a second MCP server here; the prompt below tells Maven how to use the tools for proactive onboarding.
| Tool | Use it when |
| ------------------------ | ------------------------------------------------------------------------------------------ |
| `get_live_user_activity` | Maven needs to understand what the current user recently did before answering. |
| `get_onboarding_context` | Maven needs the user's next onboarding step, completed steps, or stalled state. |
| `guide_next_step` | The user explicitly accepts the offered walkthrough and Maven should launch the next tour. |
| `list_user_tours` | The user asks what tours are available or wants to choose a tour. |
| `trigger_user_tour` | The user picks a specific tour and Maven should launch that exact tour. |
If you need to review Maven's **Agent Inclusion** settings, a typical setup is:
| Tool | Agent Inclusion |
| ------------------------ | --------------- |
| `get_live_user_activity` | When relevant |
| `get_onboarding_context` | When relevant |
| `list_user_tours` | When relevant |
| `guide_next_step` | Always |
| `trigger_user_tour` | Always |
## π€ Add the proactive Maven prompt
In Maven AGI, open your agent and go to **Agent settings** β **Response customization**.
Set **Conversation persona** to **Empathetic supporter**, then paste this into **Additional persona instructions**.
Replace:
* `` with the Autoplay `product_id` from `onboard_product`.
* `` with the product name Maven should use in user-facing language.
```text theme={null}
These instructions add live-activity awareness and proactive onboarding. They govern tool use and onboarding flow only. For identity, voice, scope, escalation, and content restrictions, follow your base persona above.
IDS (hardcoded β never ask the user for these)
product_id is ALWAYS "".
user_id comes from the session.
[AUTOPLAY LAYER 1 START] β activity-aware support
TOOL: get_live_user_activity β your live view of what this user is doing in .
Call it at the start of every conversation, before your first substantive reply.
Re-call it whenever your next answer could depend on what the user has done, or the conversation has moved on since your last call β never reason from stale activity.
Skip it only for a pure pleasantry ("hi", "thanks") that needs no product context.
Never describe the tool call or its result. Use it only to ground your answer β reference what the user has done, not raw event data.
If it fails or returns empty, answer normally without mentioning it.
[AUTOPLAY LAYER 1 END]
[AUTOPLAY LAYER 2 START] β proactive onboarding (requires Layer 1 above)
Beyond answering questions, you also help the user reach their next onboarding win by launching a guided in-app tour β but ONLY after they say yes. You offer, you don't force; you show, you don't lecture.
TOOLS
get_onboarding_context β the ONLY source of truth for where the user is (next step, what's done, whether they're stalled). Never invent, recall, or guess a step.
guide_next_step β launches the tour for the next step. Call ONLY after an explicit yes in THIS conversation.
list_user_tours / trigger_user_tour β only if the user asks to browse or pick a specific tour.
STEP 1 β OFFER (never launch here)
Trigger: the user arrives, asks what's next, or a proactive opener fires.
Call get_onboarding_context.
Reply in <=2 warm lines: acknowledge what they just did, then OFFER the next step as a yes/no question β e.g. "Nice β account connected! Want me to walk you through setting your posting schedule?"
Do NOT call guide_next_step here. You are only offering.
If the user declines: acknowledge warmly ("No problem β just ask whenever!") and do not offer again this conversation.
STEP 2 β LAUNCH (only after an explicit yes: yes / sure / okay / go ahead / show me)
Call guide_next_step.
If it returns launched=true: reply in ONE short line naming the step it launched (use step_title, in your own words β no fixed template).
If it returns done=true: there's no next step β congratulate in one line.
NEVER
Call guide_next_step, or say a walkthrough is opening, without an explicit yes in THIS conversation.
Offer more than once per conversation.
Call get_onboarding_context more than once unless the user's situation changes.
Invent, recall, or assume step state.
VOICE
Warm, human, tight. One emoji max per line. Match your base persona's tone. Never answer onboarding questions from general knowledge β read context, offer, and launch only on a yes.
[AUTOPLAY LAYER 2 END]
```
## π What a proactive nudge looks like
Once **[Autoplay.js](/quickstart#-step-3-β-add-the-proactive-layer)** is installed and dispatching nudges, your tour provider or Autoplay's built-in nudge card displays the offer without waiting for the user to ask "what's next?". Maven handles the conversation after the user accepts. See the example nudge card in **[Quickstart Step 3 β Add the proactive layer](/quickstart#make-your-chatbot-proactive)**.
## π§ͺ Test the handoff
1. Log in as a test user who has your tour provider installed and identified with the same `user_id` used by your activity source.
2. Trigger activity that maps to an onboarding step.
3. Open the Maven widget and ask: **"What's next?"**
4. Maven should call `get_onboarding_context` and offer the next walkthrough.
5. Reply **"yes"**.
6. Maven should call `guide_next_step`, and Autoplay.js should launch the tour in the user's browser.
Confirm [Autoplay.js](/quickstart#-step-3-β-connect-autoplay-js) is installed, `connectNudges` is running for this user, and your tour provider is configured. If you are using Appcues, re-check the [Appcues setup](/recipes/appcues/how-to-setup).
Check that onboarding state exists for the same `product_id` and `user_id` Maven is sending through the MCP tools.
Tighten the prompt and confirm `guide_next_step` is only called after an explicit yes in the current conversation.
Once this works, Maven can stay conversational while your tour provider handles the visual tour experience.
# Pendo β How to setup
Source: https://developers.autoplay.ai/recipes/pendo/how-to-setup
Trigger a Pendo tour from the Autoplay event stream.
Autoplay streams structured UI actions from your users' browser sessions in real time. This tutorial wires that stream to Pendo so that when the right moment arrives β a user stuck on a page, a first-time feature visit, a repeated error β your backend detects it and fires the correct Pendo tour immediately.
This tutorial builds on [How to trigger a User Tour](/recipes/user-tour/overview). Complete that guide first β it covers the proxy route, EventSource connection, and payload structure. This page covers only what is specific to Pendo.
## 1. Install Pendo
Add the snippet to your app and initialize it with your API key from **Settings β Subscription β Developer Settings**.
```html theme={null}
```
## 2. Identify the user
Call this once the user is authenticated. Use the same `userId` you send to PostHog so Autoplay can match sessions correctly.
```javascript theme={null}
pendo.initialize({
visitor: {
id: userId,
email: user.email,
full_name: user.displayName,
},
account: {
id: user.accountId,
},
});
```
## 3. Create a tour in Pendo
1. In the Pendo dashboard go to **Guides β Create Guide** and build your tour.
2. Set any targeting rules you need (page URL, user segment, etc.).
3. Publish the tour.
4. Note the **Guide ID** shown in the URL bar (`/guides//edit`).
## 4. Trigger the tour
In your `onmessage` handler from [step 3 of the manual path](/recipes/user-tour/overview#3-understanding-the-payload), add the Pendo trigger call:
```javascript theme={null}
events.onmessage = (event) => {
const payload = JSON.parse(event.data);
if (payload.type !== "usertour_trigger") return;
const mySessionId = posthog?.get_session_id?.() ?? null;
if (!mySessionId || payload.session_id !== mySessionId) return;
pendo.showGuideById(payload.flow_id);
};
```
The `flow_id` in the payload maps to the **Guide ID** of your Pendo tour, visible in the URL when editing the tour in the Pendo dashboard.
# Connect real-time events
Source: https://developers.autoplay.ai/recipes/plain-tutorial/step-1-connect-real-time-events
Attach a user's last 10 in-app actions to every Plain support thread automatically β one Machine User credential, one API route, one widget callback.
## β‘ Add this skill
Add the Autoplay Plain skill for an existing Plain AI support agent setup.
```bash CLI theme={null}
uvx --from autoplay-sdk autoplay-install-skills --chatbot plain
```
View the docs β
Fetch this skill when a customer already uses Plain and wants its AI support agent to consume Autoplay live user activity.
```bash cURL theme={null}
curl -s https://developers.autoplay.ai/chatbot-plain/SKILL.md
```
View the skill β
**Plain** surfaces live user context the moment a support thread opens β no asking, no digging. The moment a user opens a new thread, a server-side route fetches their recent Autoplay actions and writes them as a note β your support team sees exactly what the user was doing before they asked for help.
This guide assumes you **already have a Plain workspace with a Live Chat app created**. If you don't have one yet, see [Plain's Machine User docs](https://www.plain.com/docs/agents/machine-users) to set one up first, then come back here.
**What Autoplay needs from your Plain setup:**
* **Live Chat App ID** (`liveChatApp_...`) β from the Live Chat app you created.
* **Machine User API key** (Settings β Machine users) β only needed if you haven't created one yet; skip if it's already running.
* **`mcp_url` and `mcp_key`** β printed by your own `onboard_product` call (see [Quickstart](/quickstart)), not something Plain issues.
## π¬ Watch the walkthrough
Prefer to watch first? This Loom walks through the full setup β workspace, Machine User, and a working chat bubble.
## How it works
A server-side API route bridges the Autoplay SDK and Plain's GraphQL API. Here's the full sequence:
1. **User opens a thread in Plain** β Plain fires the `onNewThread` callback in your widget.
2. **Widget POSTs to your webhook** β sends `{ customerId, threadId }`.
3. **Server fetches live activity** β calls the Autoplay SDK for the user's last 10 in-app actions, scoped to your Product ID.
4. **Server resolves the Plain customer** β queries Plain's GraphQL API to get the Plain-internal customer ID from the thread.
5. **A note appears on the thread** β your team sees the last 10 actions before they've typed a word.
## Prerequisites
* A Plain workspace with a Live Chat app created and the `liveChatApp_...` App ID copied
* A Plain Machine User with an API key (see below)
* A registered Autoplay product β run `onboard_product` from the [Quickstart](/quickstart) if you haven't yet
## π€ Create a Machine User
A Machine User is a service account that authenticates server-side API calls to Plain β this integration reads thread data and writes notes on behalf of this user. Create one in **Settings β Machine users**, assign at least the **Member** role (required to read threads and create notes), then generate an API key. See [Plain's Machine User docs](https://www.plain.com/docs/agents/machine-users) for the setup flow.
## π Set your environment variables
Running `onboard_product` from the [Quickstart](/quickstart) registers your product and prints your Autoplay credentials to the terminal, including `mcp_url` and `mcp_key`. Map those into the variables below and add all four to your environment config before running.
**This integration doesn't speak MCP.** `mcp_url`/`mcp_key` are just the credential names `onboard_product` returns β they're named after the connector's primary MCP interface, but Plain has no MCP client, so this integration calls the plain REST live-activity endpoint directly instead.
| Env variable | Description |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `PLAIN_API_KEY` | Machine User API key β Plain β Settings β Machine users |
| `CONNECTOR_URL` | Host that serves the live-activity read API, `https://mcp.autoplay.ai` (the origin of your `mcp_url`, without the `/mcp` path) |
| `MCP_KEY` | Bearer token for the live-activity API β the `mcp_key` from your product registration |
| `PRODUCT_ID` | Your Autoplay Product ID β scopes activity queries to your product |
All four are server-side secrets or identifiers β never prefix them with `NEXT_PUBLIC_` (or any framework's client-exposure prefix) or reference them in client-side code.
## π Create the API route
Write the handler once as plain JavaScript, with no framework imports β then mount it under whatever server you run. It receives `{ customerId, threadId }` from the widget, fetches the user's recent Autoplay actions, and attaches them as a note on the Plain thread.
```js theme={null}
const CONNECTOR_URL = process.env.CONNECTOR_URL ?? "https://mcp.autoplay.ai";
const MCP_KEY = process.env.MCP_KEY ?? "";
const AUTOPLAY_PRODUCT_ID = process.env.PRODUCT_ID ?? "";
const PLAIN_API_KEY = process.env.PLAIN_API_KEY ?? "";
const PLAIN_API_URL = "https://core-api.uk.plain.com/graphql/v1";
async function plainRequest(query, variables) {
const res = await fetch(PLAIN_API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${PLAIN_API_KEY}`,
},
body: JSON.stringify({ query, variables }),
});
return res.json();
}
async function getPlainCustomerIdFromThread(threadId) {
const data = await plainRequest(
`query GetThread($threadId: ID!) {
thread(threadId: $threadId) {
customer { id }
}
}`,
{ threadId }
);
return data?.data?.thread?.customer?.id ?? null;
}
// Call this from your route/controller with the parsed JSON body.
export async function handlePlainChatWebhook({ customerId, threadId }) {
if (!customerId || !threadId) return { ok: true };
const url = `${CONNECTOR_URL}/users/${encodeURIComponent(AUTOPLAY_PRODUCT_ID)}/${encodeURIComponent(customerId)}/live-activity`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${MCP_KEY}` },
});
if (!res.ok) {
console.log(`[plain-chat] Autoplay fetch failed: ${res.status} for user=${customerId}`);
return { ok: true };
}
const { actions = [] } = await res.json();
console.log(`[plain-chat] user=${customerId} actions=${actions.length}`);
if (actions.length > 0) {
const plainCustomerId = await getPlainCustomerIdFromThread(threadId);
console.log(`[plain-chat] threadId=${threadId} plainCustomerId=${plainCustomerId ?? "null"}`);
if (plainCustomerId) {
const lines = actions
.slice(-10)
.reverse()
.map((a, i) => `${i + 1}. ${a.description}`);
const noteText = `Recent user activity (last 10 actions): ${lines.join(". ")}`;
console.log(`[plain-chat] writing note (${lines.length} actions): ${noteText.slice(0, 80)}β¦`);
}
}
return { ok: true };
}
```
Wire `handlePlainChatWebhook` to a `POST` endpoint on your server β pass it the parsed JSON body and return its result as the JSON response. The widget below expects it at `/plain-chat-webhook`, but the path is arbitrary as long as it matches what the widget script fetches.
## π¬ Add the `onNewThread` callback
Add this plain `` tag, right after the snippet that sets `USER_ID` below.
## π Wire up identity β set `USER_ID` from your server
Whatever server-renders the page must set `USER_ID` **before** `plain-chat-widget.js` loads, using the logged-in user's Autoplay id. Always JSON-encode the value when inlining it into a `
```
Resolve the user id from your own auth/session layer and JSON-encode it with whatever your server language provides β the only requirement is that this snippet renders before the widget script tag.
**Use the same ID you track events with in Autoplay.** If your Autoplay activity is stored under a numeric database user ID, pass that β not email or display name. A mismatch returns an empty action list and no note is created.
**How the pieces fit:** your app identifies the user in Autoplay β Autoplay stores activity under that id β your server sets `USER_ID` to that same id before the widget script loads β the widget sends it to `/plain-chat-webhook` β the handler fetches activity for that exact id β the note appears on the thread.
## β Test the full loop
1. Set all four environment variables and restart your server
2. Log in to your app as a test user
3. Interact with your app for a few minutes β visit pages, click buttons β so Autoplay has recorded actions for this user
4. Open the Plain chat widget and send a first message (this creates a new thread)
5. Open the thread in your Plain inbox β you should see a note **"Recent user activity (last 10 actions)"** automatically attached
* **Note never appears?** Check your server logs β the route logs the action count, resolved Plain customer ID, and note text. If `actions.length` is `0`, the user has no Autoplay activity yet β interact with the app first, then open a fresh thread.
* **`onNewThread` not firing?** Open the browser console and confirm `Plain.init()` ran without errors.
* **`401 Unauthorized`?** Re-copy `PLAIN_API_KEY` from Plain β Settings β Machine users.
**"No note" = identity mismatch.** Confirm the **same** value in all three:
1. the id your activity source identifies the user with,
2. the `userId` your server resolves and sets on `USER_ID`,
3. the `customerId` arriving at `/plain-chat-webhook`.
If they don't match, activity is stored under one key and fetched with another β the lookup returns empty and no note is written.
### Why `upsertCustomTimelineEntry` no longer works
Older Plain SDK versions (β€ 2.x) and some Plain support documentation reference a mutation called `upsertCustomTimelineEntry`. **This mutation has been permanently removed from Plain's GraphQL API server-side** β it does not appear in the schema returned by any key type (Machine User or workspace admin).
Confirmed via live schema introspection (June 2026):
```
"Cannot query field \"upsertCustomTimelineEntry\" on type \"Mutation\""
"Unknown type \"UpsertCustomTimelineEntryInput\""
```
Downgrading `@team-plain/typescript-sdk` to v2.x makes the method reappear in your IDE but the call fails at runtime with the same error β the SDK is just a wrapper around the same GraphQL endpoint.
Plain split `upsertCustomTimelineEntry` into two replacements in SDK v3.0.0:
| Mutation | Scope | Plan required |
| --------------------- | ---------------------------------- | ---------------------- |
| `createCustomerEvent` | Customer timeline (Ari reads this) | Events API β paid plan |
| `createThreadEvent` | Thread timeline (Ari reads this) | Events API β paid plan |
### Implementation (Events API plan required)
**This requires Plain's Events API**, which is gated behind a paid plan. The `createCustomerEvent` and `createThreadEvent` mutations return `FORBIDDEN` on the Foundation (\$35/month) plan even with correct permissions set on the Machine User. Verify your plan at [plain.com/pricing](https://plain.com/pricing) before implementing.
When the Events API is unlocked on your plan, with `createCustomerEvent` for Ari context. Run both in parallel so human agents see the note too:
```ts theme={null}
async function createCustomerEvent(email: string, text: string) {
const truncated = text.length > 1900 ? text.slice(0, 1900) + "β¦" : text;
return plainRequest(
`mutation CreateCustomerEvent($input: CreateCustomerEventInput!) {
createCustomerEvent(input: $input) {
customerEvent { id }
error { message type }
}
}`,
{
input: {
customerIdentifier: { emailAddress: email },
title: "Recent User Activity",
components: [{ componentPlainText: { plainText: truncated } }],
},
}
);
}
async function createThreadEvent(threadId: string, text: string) {
const truncated = text.length > 1900 ? text.slice(0, 1900) + "β¦" : text;
return plainRequest(
`mutation CreateThreadEvent($input: CreateThreadEventInput!) {
createThreadEvent(input: $input) {
threadEvent { id }
error { message type }
}
}`,
{
input: {
threadId,
title: "Recent User Activity",
components: [{ componentPlainText: { plainText: truncated } }],
},
}
);
}
// In your POST handler β run all three in parallel:
await Promise.all([
createCustomerEvent(userEmail, noteText), // Ari reads this (paid plan)
createThreadEvent(threadId, noteText), // Ari reads this (paid plan)
]);
```
**Timing matters.** The event must be written to the thread **before** Ari is assigned. In the Plain workflow, the order must be: HTTP request step (your route) β Assign to AI agent. If Ari is assigned first, it won't see the event.
### Machine User permissions required
For `createCustomerEvent` and `createThreadEvent`, grant these permissions to the Machine User in Plain β Settings β Machine users:
* `customerEvent:create`
* `threadEvent:create`
* `thread:read` (to look up the customer from the thread ID)
Without `thread:read` you'll get `FORBIDDEN: missing thread:read`. Without `customerEvent:create` / `threadEvent:create` you'll get `FORBIDDEN: missing [permission]`. Once permissions are correct but plan is insufficient, you get `FORBIDDEN: Events APIs are not available on your current billing plan`.
***
Once notes are appearing on Plain threads automatically, jump into our [Discord](https://discord.gg/jCbR2tQA5) β we'll confirm the enrichment is pulling activity cleanly and help you tune what gets surfaced to your support team.
Once Plain is enriching threads automatically, you're done with Step 1. Next: **[Step 2 β Define proactive triggers](/recipes/plain-tutorial/step-2-define-proactive-triggers)**.
# PostHog β How to setup
Source: https://developers.autoplay.ai/recipes/posthog/how-to-setup
Learn how to connect existing PostHog live user activity to your support AI agent using the Autoplay SDK.
## β‘ Add this skill
Add the Autoplay PostHog session replay provider skill for an existing PostHog setup.
```bash CLI theme={null}
uvx --from autoplay-sdk autoplay-install-skills --user-activity posthog
```
View the docs β
Fetch this skill when a customer already uses PostHog as a session replay provider and wants Autoplay live user activity.
```bash cURL theme={null}
curl -s https://developers.autoplay.ai/activity-posthog/SKILL.md
```
View the skill β
**Prerequisite:** install the SDK first β see [Quickstart](/quickstart).
This guide assumes you **already have PostHog set up** and capturing events in your app. Register with your existing PostHog project id; Autoplay issues an opaque **`product_id`** for the ingest URL and live-activity reads. If you don't have a PostHog project yet, see [PostHog's docs](https://posthog.com/docs/getting-started/install) to set one up first, then come back here.
**What Autoplay needs from your PostHog project:**
* **Project ID** β links your PostHog project to the Autoplay product Autoplay will issue. Find it in the URL while logged into your project (the numeric value following `/project/`), or under **Project Settings** in the sidebar.
* **Project API Key** (`phc_...`) β the public key your `posthog.init()` call already uses.
* **Personal API Key** (`phx_...`) β only needed if you want the SDK to create the webhook destination for you (Step 3, Option A).
### π― Step 1 β Get credentials from your existing PostHog setup
Find your **Project ID** and **Project API Key** (Settings β Projects β \[Your Project] β General; the key starts with `phc_` β not the `phx_` Personal API Key, which `posthog.init()` rejects).
**πΊ How to find your PostHog Project ID and Project API Key**
**Save your Project ID** β you will use it to register in Step 2 and configure the destination in Step 3.
Your app should already have `posthog-js` installed and initialized β see [PostHog's library docs](https://posthog.com/docs/libraries) if you need to check the install/`init()` pattern for your framework (that page already shows the full `posthog.init(...)` call β no need to repeat it here). The one Autoplay-specific addition, inside your `init`'s `loaded` callback:
```javascript theme={null}
// Inside posthog.init(...)'s `loaded` callback.
// Use the product_id issued by onboard_product in Step 2.
posthog.register({ product_id: 'YOUR_AUTOPLAY_PRODUCT_ID' });
```
**`api_host` must match the destination's region.** Whatever host your `posthog.init()` sends events to (`us.i.posthog.com` or `eu.i.posthog.com`) must be the same region you pass as `host` when registering the destination in Step 3 (`PostHogProvider.create_destination(host=...)`). If they don't match, nothing flows β PostHog itself has no way to warn you about this, since it's specific to how Autoplay provisions the destination.
**Required: identify users on login.** Your app most likely already calls `posthog.identify()` with **your own user id** somewhere in the login flow (never the anonymous `posthog.get_distinct_id()`) β see [PostHog's identify docs](https://posthog.com/docs/product-analytics/identify) if you need to check the general `identify()` / `reset()` pattern (that page already covers login/logout and why to avoid the anonymous id). The Autoplay-specific addition: include `product_id` in the identify traits (`email` is optional but recommended β it enables email-based scoping). This makes PostHog's `distinct_id` equal your app's user id β so the same id reaches Autoplay as `user_id` β and links earlier anonymous activity to the identified person. Merge these fields into your existing call (or use this as the full call if you don't have one yet):
```javascript theme={null}
// On login (and on app load when restoring an already-logged-in session):
posthog.identify(user.id, { // user.id = YOUR app's stable user id
product_id: 'YOUR_POSTHOG_PROJECT_ID', // must match onboard_product(product_id=...) in Step 2
email: user.email, // optional β recommended, enables email-based scoping
// Optional: only if you're segmenting who should receive the Autoplay experience.
// Replace this condition with your own rule: plan, signup date, feature flag,
// experiment assignment, workspace type, region, or any other property.
autoplay_experiment_group: isInAutoplayExperiment(user) ? 'autoplay' : 'comparison',
autoplay_experiment_id: 'your-experiment-id',
});
// On logout:
posthog.reset(); // clears identity so the next user starts clean
```
Don't call `posthog.identify(posthog.get_distinct_id(), β¦)` β that re-stamps
the anonymous id and never sets a real user id. Always pass your app's stable
user id. Until a user logs in they stay anonymous (that's expected); the
`session_id` still scopes everything.
**Running an Autoplay experiment?** Set `autoplay_experiment_group` and `autoplay_experiment_id` (or any custom traits) based on whatever condition decides eligibility. After PostHog receives the `identify` call and a later event for that user, these ride through as **person properties** and can be used to filter the destination you create in [Step 3](#-step-3-β-set-up-your-posthog-webhook) β so only the `autoplay` group's events stream to Autoplay while comparison-group activity stays in PostHog.
> **π Quick Tip:** Once you add this code to your site, jump into our [Discord](https://discord.gg/jCbR2tQA5) and say hi β we will check your data is flowing and help you get fully set up!
**Identity plumbing for widget-based support AI agents:** make sure the same user identity flows across all three layers: PostHog `distinct_id` / `user_id`, your chat widget session metadata, and the support AI agent backend sender identifier. If those do not match, chat replies will look like "no recent activity" because events are stored under one key and fetched with another.
***
### π Step 2 β Register your product with Autoplay
Now that your website is tracking clicks, we need to create a secure "ingest\_url" (**Webhook URL**) and a shared **secret** (`X-PostHog-Secret`) so that data can be safely sent to Autoplay.
The `autoplay-sdk` was installed on the [Quickstart](/quickstart) page. Create a Python file with the script below, replace the placeholders with your values from Step 1, and run it once:
```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_POSTHOG_PROJECT_ID", # your PostHog project ID from Step 1
contact_email="you@yourcompany.com", # replace with your actual email
user_activity_provider=PostHogProvider(),
print_operator_summary=True,
)
asyncio.run(main())
```
This will print the following fields:
* **product\_id:** `prod_wQ7r8kF9...` β the issued Autoplay id
* **provider:** `posthog`
* **provider\_project\_id:** `YOUR_POSTHOG_PROJECT_ID`
* **ingest\_url:** `https://connector.autoplay.ai/ingest/prod_wQ7r8kF9...`
* **ingest\_secret:** `{secret}` β PostHog sends this as the `X-PostHog-Secret` header
* **mcp\_url:** `https://mcp.autoplay.ai/mcp`
* **mcp\_key:** `{secret}` β your agent's Bearer token
* **owner\_token:** `{secret}` β shown only on first registration; save it securely
**Save what prints in the terminal** β you will need these values in Step 3 and Step 4:
* **Step 3 (PostHog webhook):** use the `ingest_url` and `ingest_secret` printed above
* **Step 4 (read live activity):** use the `mcp_url` and `mcp_key` printed above (`mcp_key` is your Bearer token)
* **Future re-registration/rotation:** save `owner_token`; it is required and is not shown again
**Re-registering your product**
* A second `onboard_product` with the same provider/project pair returns **409** unless you pass the saved **`owner_token`**.
* Re-run with `owner_token=""`. You must still pass **`contact_email`** on every registration.
* After a successful re-registration, the **`ingest_secret` rotates**. Update PostHog (**Step 3**) so **`X-PostHog-Secret`** matches the new secret.
***
### π Step 3 β Set up your PostHog webhook
Now we must tell the website tracker (Step 1) to send its data to the secure address (webhook) you just generated (Step 2).
**You have three choices:**
**Option A β Automated with the SDK (recommended)**
Let the SDK create and verify the destination for you β no clicking around in
PostHog, no pasting Hog code. Run this once with the values from Step 2:
**Where to find your PostHog Personal API Key:** In PostHog, go to **Settings β \[Your name] β Personal API keys β Create personal API key**. Give it `project:read` and `hog_function:write` permissions β see [PostHog's Personal API key docs](https://posthog.com/docs/api/personal-api-keys) if you need more detail. This is different from the Project API Key used in `posthog.init()`.
**πΊ Generate your PostHog Personal API Key**
```python theme={null}
import asyncio
from autoplay_sdk.providers import PostHogProvider
async def main() -> None:
provider = PostHogProvider()
dest = await provider.create_destination(
host="https://us.posthog.com", # your PostHog host (us.posthog.com or eu.posthog.com)
project_id="YOUR_POSTHOG_PROJECT_ID", # your project ID from Step 1
personal_api_key="YOUR_PERSONAL_API_KEY", # phx_... Personal API Key from the Tip above
webhook_url="YOUR_INGEST_URL", # ingest_url printed by Step 2
webhook_secret="YOUR_INGEST_SECRET", # ingest_secret printed by Step 2
)
status = await provider.verify(
host="https://us.posthog.com",
project_id="YOUR_POSTHOG_PROJECT_ID",
personal_api_key="YOUR_PERSONAL_API_KEY",
destination_id=dest.id,
)
print("destination", dest.id, "enabled:", status.ok)
asyncio.run(main())
```
It's **idempotent** β it creates the "Autoplay Event Stream" destination (or
updates it) and confirms it's enabled, so you can re-run it safely.
**Option B β Managed**
* Join our [Discord](https://discord.gg/jCbR2tQA5) and say hi.
* We configure the PostHog webhook for you.
* You receive a **1Password** link with your `ingest_url`, `ingest_secret`, and `mcp_key`.
**Option C β Fully manual (advanced)**
Add the destination by hand in PostHog β only needed if you can't run Option A:
* In PostHog, add a **Webhook** destination.
* **Webhook URL:** paste the `ingest_url` printed by Step 2.
* **`X-PostHog-Secret` header:** paste the `ingest_secret` printed by Step 2. Do **not** create a new secret.
PostHog still requires the form-level **Webhook URL** field even if your Hog source code also sets `let url := ...`. For the general mechanics of adding a webhook destination in PostHog's UI, see [PostHog's destinations docs](https://posthog.com/docs/cdp/destinations) β the Autoplay-specific part is the Hog source below, which shapes PostHog's event data into the payload Autoplay expects.
**PostHog webhook setup walkthrough**
Paste this into the **Event Body / Source** field of your PostHog webhook destination:
```text 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) {
print(f'extractFromElementsChain error for pattern {pattern}:', err)
return ''
}
}
let elements_chain := event.elements_chain ?? ''
let element_id := ''
let input_field_name := ''
let link_destination := ''
let button_or_link_text := ''
try {
element_id := extractFromElementsChain(elements_chain, 'attr__id="')
} catch (err) {
print('Error extracting element_id:', err)
element_id := ''
}
try {
input_field_name := extractFromElementsChain(elements_chain, 'attr__name="')
} catch (err) {
print('Error extracting input_field_name:', err)
input_field_name := ''
}
try {
link_destination := extractFromElementsChain(elements_chain, 'attr__href="')
} catch (err) {
print('Error extracting link_destination:', err)
link_destination := ''
}
try {
button_or_link_text := extractFromElementsChain(elements_chain, 'text="')
} catch (err) {
print('Error extracting button_or_link_text:', err)
button_or_link_text := ''
}
let payload := {
'event': event.event,
'referrer': event.properties?.$referrer ?? '',
'email': event.properties?.email ?? event.person?.properties?.email ?? '',
'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 ?? '',
'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
if (inputs.debug) {
print('Request payload', payload)
print('Request', url, req)
}
let res := fetch(url, req)
if (res.status >= 400) {
print('Webhook error response', res.status, res.body)
throw Error(f'Webhook returned {res.status}: {res.body}')
}
if (inputs.debug) {
print('Response', res.status, res.body)
}
```
**Optional: limit streaming to an experiment cohort**
If you're running an Autoplay onboarding experiment, filter the destination you just created (whichever option above you used β the destination is the same "Autoplay Event Stream" hog function either way) so only the experiment group's events reach Autoplay:
1. In PostHog, go to **Data pipeline β Destinations** and open **Autoplay Event Stream**.
2. Add a filter condition on the property you set in [Step 1](#-step-1-β-get-credentials-from-your-existing-posthog-setup) β **Person properties β `autoplay_experiment_group` β equals β `autoplay`**, optionally combined with any other eligibility property (e.g. `plan β equals β trial`).
3. Use the destination's built-in **Testing** tab to send a real event and confirm it only fires for a person carrying that property.
This means PostHog will not forward comparison-group events to the Autoplay connector for this destination. Keep the assignment stable in your app or experimentation system so users do not move between groups during the trial.
See PostHog's [destination filtering docs](https://posthog.com/docs/cdp/destinations) for the general filter-builder walkthrough β the Autoplay-specific part is just which property to filter on, not the mechanics of PostHog's filter UI.
**Validated against a live PostHog project.** A destination filtered to `autoplay_experiment_group` (person property) `exact`-matching `autoplay` only invoked for events from a person carrying that value β a comparison-group person's `$identify` and `$pageview` events never triggered it, while the same events reached an unfiltered destination normally. PostHog compiles a `properties` condition like this into the same `filters` field the dashboard and API both read, so it behaves identically regardless of whether the destination was created via Option A, B, or C above.
Unlike Amplitude's template, the Hog script above only forwards a fixed set of fields to Autoplay (`event`, `email`, `timestamp`, `session_id`, `current_url`, etc.) β it does **not** include `autoplay_experiment_group`/`autoplay_experiment_id` in the payload itself. These properties only gate whether the destination fires; they won't appear in the activity Autoplay stores.
PostHog's own filter UI warns: *"You are filtering on Person properties. Be aware that this filtering applies at the time the event is processed so if Person Profiles are not enabled or the person property has not been set by then then the filters may not work as expected."* In practice: make sure the `identify()` call from [Step 1](#-step-1-β-get-credentials-from-your-existing-posthog-setup) fires *before* the events you want filtered, not after.
**Do not rely on Autoplay to tell you who was excluded.** If comparison-group events are filtered out here, they never reach the Autoplay connector β Autoplay only ever sees the `autoplay` group. Keep the experiment assignment in your app, PostHog, or your warehouse as the source of truth for evaluation, and compare conversion by `autoplay_experiment_group` (or your equivalent property). Keep the assignment stable in your app β don't randomize it on each page load.
***
### π‘ Step 4 β See your activity land
Everything is wired up! The connector is **pull-based** β instead of streaming, you (or your agent) ask for a user's recent activity the moment you need it. Let's confirm your events are landing.
Click around your app while logged in as an identified user, then fetch that user's activity with the `mcp_key` you saved from Step 2:
```bash theme={null}
curl "https://mcp.autoplay.ai/users/YOUR_AUTOPLAY_PRODUCT_ID/YOUR_USER_ID/live-activity?limit=10" \
-H "Authorization: Bearer YOUR_MCP_KEY"
```
* `YOUR_USER_ID` β the stable id you pass to `posthog.identify(...)`.
* `YOUR_AUTOPLAY_PRODUCT_ID` β the issued `product_id` printed by Step 2.
* `YOUR_MCP_KEY` β the `mcp_key` printed by Step 2.
**What you'll get back** β the user's recent footsteps, ordered oldest β newest:
```json theme={null}
{
"product_id": "YOUR_AUTOPLAY_PRODUCT_ID",
"user_id": "user_12345",
"count": 3,
"as_of": 1736940750.881,
"actions": [
{ "type": "pageview", "title": "Page Load: Dashboard", "description": "User landed on the dashboard page", "canonical_url": "https://app.example.com/dashboard", "index": 0 },
{ "type": "click", "title": "Click Export Csv", "description": "User clicked the Export Csv button", "canonical_url": "https://app.example.com/dashboard", "index": 1 },
{ "type": "submit", "title": "Submit Payment Form", "description": "User submitted the Payment form", "canonical_url": "https://app.example.com/checkout", "index": 2 }
]
}
```
A **`200` with a populated `actions` array** means your events are flowing. An **empty array** means the user identified but hasn't browsed yet (activity is built from `$pageview` / `$autocapture`), or the events haven't landed yet β click around and give it a few seconds.
This REST call returns the **exact same data your agent reads** β the agent just pulls it over **MCP** (the `get_live_user_activity` tool) instead of curl. That's the next step.
***
### π Next: connect your AI support agent
Your activity is now flowing into the connector. Head back to Quickstart to choose your existing AI support agent and connect it via MCP β it then pulls a user's live activity on demand, the moment it needs context to answer.
Fin (Intercom), Maven, Ada, Botpress, and more β pick yours and connect via MCP.
For structured logging and `extra` field conventions used across the SDK, see [Logging](/sdk/logging). Release history is on the [Changelog](/changelog).
# Rasa tutorial
Source: https://developers.autoplay.ai/recipes/rasa/index
Give your Rasa support AI agent live awareness of what users are doing in your web app β using Autoplay's open SDK, fully self-hosted.
**What this means in practice:** a user is half-way through an upgrade flow. They open the chat bubble and ask "how do I finish this?" Without context, your bot has to ask which page they're on. With Autoplay wired in, your bot already knows β and replies with the specific next click, not a walkthrough of the whole UI.
This tutorial shows you exactly how to wire it together using **Rasa 3.6** (open-source, self-hosted) and the **Autoplay SDK**. Everything stays on your infrastructure β your activity data, prompts, and LLM keys never leave it.
**Who this is for:**
* You already have (or are building) a Rasa-based support AI agent for your product.
* You want it to give answers that adapt to where the user is in the UI, instead of canned responses.
* You're comfortable with Python, Docker, and a small FastAPI service.
**Tech you'll use:** Rasa 3.6 (Docker), Python 3.10+ (host), PostHog (free tier), the Autoplay event connector (hosted), and any LLM with an async Python client (OpenAI, Anthropic, Gemini, local β your choice).
**SDK primitives you'll touch:** a plain `httpx` GET against the Autoplay live-activity REST endpoint (no client class to import β it's just a URL, a Bearer token, and `product_id`/`user_id`), `agent_state.v2.SessionState`, `ChatContextAssembly`, plus (in Step 2) `PredicateProactiveTrigger`, `ProactiveTriggerRegistry`, and `TourRegistry`. The connector is pull-based, so there's no connection to manage β the bridge fetches a user's recent activity the moment it needs it, and the SDK still handles prompt assembly and FSM transitions.
## β¨ Final result
```text theme={null}
User: how do I add a teammate?
Bot: Go to Settings β Team β Invite member, then enter their email
and pick a role.
(Generic reply β the bot doesn't know the user is already on the
Team page with the invite modal half-filled.)
```
```text theme={null}
User: how do I add a teammate?
Bot: You're nearly there β just pick a role from the dropdown and
hit Send invite. They'll get the email within a minute.
(Same question, but the bot infers from recent activity that the
user is already in the right modal and skips the steps they've
already done. No mention of "I see you clickedβ¦".)
```
**Video walkthrough** β [Open on Loom](https://www.loom.com/share/d748e02164014d4cbcad568dbc4b3c05) if the player does not load.
***
## π Prerequisites
Before you start, you need:
* **A web app you can edit** β anything that can load `posthog-js`. The examples use Next.js but it works with any frontend.
* **A free [PostHog](https://posthog.com) account** β for click capture. You'll need your **Project API Key** (starts with `phc_`, not `phx_` β the personal one is rejected by `posthog.init()`).
* **An Autoplay product ID.** The tutorial covers running `onboard_product` with your PostHog project id; Autoplay returns the issued `product_id` plus ingest, MCP, and owner credentials.
* **Docker Desktop** β Rasa runs in a container; this also sidesteps TensorFlow ABI issues on Apple Silicon.
* **Python 3.10+** on the host β for the small FastAPI bridge service.
* **An OpenAI API key** (or any LLM provider with an async Python client) β the bridge calls it directly with a plain `(str) -> str` async function, so swap in Anthropic, Gemini, Mistral, or a local model if you prefer.
That's all the setup. The rest of this tutorial walks through every code file step by step β copy-paste runnable.
**Why a separate "bridge" service?** Rasa runs in Docker and can't import Python objects from your host. The bridge is a small FastAPI service that owns the Autoplay SDK pipeline and exposes a tiny HTTP surface (`/reply/{user_id}`) that Rasa's action server calls when a user chats. This same pattern works for any cross-process support AI agent β Botpress on-prem, LangChain services, Twilio webhooks β not just Rasa.
***
## Architecture
```
ββββββββββββββββββββ ββββββββββββ ββββββββββββ
β Your web app ββββΆβ PostHog ββββΆβ Autoplay β
β + posthog-js β β autoclickβ β connectorβ
β + chat widget β β + HogQL β β (pull API)β
ββββββββββ²ββββββββββ ββββββββββββ βββββββ²βββββ
β β GET .../live-activity
β β (on demand, no open connection)
β ββββββββββββββββββββββ΄βββββββββββββββββ
β β bridge/ (autoplay-native, FastAPI)β
β β β
β β GET /reply/{user_id}?query=β¦ β
β β β httpx GET live-activity β
β β β format actions as text β
β β β ChatContextAssembly / β
β β build_user_prompt_block β
β β β call your LLM β
β β β return reply β
β β β
β β agent_state.v2.SessionState (FSM) β
β ββββββββββββββββββ¬βββββββββββββββββββββ
β β HTTP
β β
ββββββββββ΄βββββββββββββββββββββββββββββββ΄ββββββββββββββββββββββ
β Rasa server (Docker) β Rasa action server (Docker) β
β βββΆ HTTP GET to bridge /reply β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
```
Four processes: your web app, a small Python bridge, Rasa, Rasa's action server. There is no persistent stream to manage β the bridge fetches a user's recent activity synchronously, at request time, over a stateless REST call. Rasa stays a thin chat surface that calls the bridge over HTTP.
***
## The tutorial
1. **[Step 1 β Connect real-time events](./step-1-connect-real-time-events)** β Pull a user's live activity on demand into a Rasa-aware bridge using the Autoplay SDK's REST read, expose it to Rasa over HTTP, and wire the chat widget. Reactive answers grounded in real activity. \~45 minutes.
2. **[Step 2 β Define proactive triggers](./step-2-define-proactive-triggers)** β Make the bot proactive: notice when a user is on the slow path of a workflow and surface a toast with two CTAs β *Show me* (visual tour via Usertour) or *Open chat* (proactive bot message with an LLM-grounded auto-followup). Uses `PredicateProactiveTrigger`, `agent_state.v2.SessionState`, and `TourRegistry`. \~45 minutes.
Start with Step 1 β Step 2 builds directly on the bridge, Rasa, and widget you set up there.
# Connect real-time events
Source: https://developers.autoplay.ai/recipes/rasa/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.
## β‘ Add this skill
Add the Autoplay Rasa skill for an existing Rasa AI support agent setup.
```bash CLI theme={null}
uvx --from autoplay-sdk autoplay-install-skills --chatbot rasa
```
View the docs β
Fetch this skill when a customer already uses Rasa and wants its AI support agent to consume Autoplay live user activity.
```bash cURL theme={null}
curl -s https://developers.autoplay.ai/chatbot-rasa/SKILL.md
```
View the skill β
This tutorial gets you from zero to a working Rasa bot with grounded replies in about 45 minutes.
### End-to-end walkthrough (watch first)
***
This guide builds a **Rasa bot from scratch** in Docker β you don't need an existing Rasa deployment. If you already run Rasa in production, skip Steps 6β8 (scaffolding a new bot) and wire your existing action server to the bridge in Step 5 instead.
**Before you start, make sure you have:**
* **A PostHog account** (free tier is fine) β for the click-capture step. See [PostHog's own docs](https://posthog.com/docs/getting-started/install) if you don't have one yet.
* **Docker Desktop** β Rasa 3.x runs in Docker to sidestep TensorFlow ABI issues on Apple Silicon.
* **An OpenAI API key** and **\~45 minutes** β for the bridge's grounded replies.
**What you'll build:**
1. Frontend autocapture with `posthog.identify()` + `posthog.register({email})`.
2. Product onboarding with your PostHog project id for issued Autoplay `product_id`, ingest, MCP read, and owner 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.
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.
```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_POSTHOG_PROJECT_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 the registration values β save them:
* `product_id` β the issued Autoplay id, e.g. `prod_wQ7r8kF9...`.
* `provider` β `posthog`.
* `provider_project_id` β your PostHog project id.
* `ingest_url` β PostHog will POST events here (e.g. `https://connector.autoplay.ai/ingest/prod_wQ7r8kF9...`).
* `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.
* `owner_token` β shown only on first registration; save it securely for future re-registration or rotation.
**`contact_email` is required.** It is stored on the connector product row so Autoplay can reach you. Re-registering the same provider/project pair returns **409** until you pass **`owner_token=""`** β after a successful re-registration, **`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.
```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}')
}
```
6. Click **Test function** β expect status 200 in under 200 ms.
7. **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:
```bash theme={null}
mkdir -p ~/your-copilot/rasa-bot
cd ~/your-copilot/bridge
uv add python-dotenv fastapi 'uvicorn[standard]' openai httpx
```
**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_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=
PRODUCT_ID=YOUR_AUTOPLAY_PRODUCT_ID
OPENAI_API_KEY=sk-...
# Optional tuning:
LLM_MODEL=gpt-4o-mini
MAX_ACTIONS=30
```
**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
```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"])
```
### 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.
```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))
```
**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.
```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]
```
**`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
```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
```
**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.
```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()),
}
```
**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
```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`:
```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
```
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
```
**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):
```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
```
Create `rasa-bot/domain.yml`:
```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
```
Now the three training files under `rasa-bot/data/`.
Create `rasa-bot/data/nlu.yml`:
```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
```
Create `rasa-bot/data/rules.yml`:
```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
```
Create `rasa-bot/data/stories.yml`:
```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
```
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
```
**`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.
```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 []
```
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.
# Connect real-time events
Source: https://developers.autoplay.ai/recipes/tidio/step-1-connect-real-time-events
Give Lyro live awareness of what each user is doing β Lyro pulls it on demand via an Action that calls the Autoplay MCP server.
## β‘ Add this skill
Add the Autoplay Tidio Lyro skill for an existing Tidio Lyro AI support agent setup.
```bash CLI theme={null}
uvx --from autoplay-sdk autoplay-install-skills --chatbot tidio
```
View the docs β
Fetch this skill when a customer already uses Tidio Lyro and wants its AI support agent to consume Autoplay live user activity.
```bash cURL theme={null}
curl -s https://developers.autoplay.ai/chatbot-tidio/SKILL.md
```
View the skill β
**Lyro** (Tidio's AI agent) pulls a user's recent in-app activity on demand via a **Lyro Action** β a named API call that Lyro triggers when it needs context to answer. One Action, one API call to the Autoplay MCP server, and a user id wired in from the visitor session.
This guide assumes you **already have Lyro AI Agent set up** in your Tidio project. If you don't have it configured yet, see [Tidio's own Lyro setup guide](https://help.tidio.com/hc/en-us/) to set it up first, then come back here.
**What Autoplay needs from your Tidio setup:**
* **`mcp_key`** β printed by your own `onboard_product` call (see [Quickstart](/quickstart)), not something Tidio issues.
* **A stable `user_id`** you can set via `tidioChatApi.setVisitorData(...)` β the same id your activity source (PostHog/Amplitude) identifies the user with.
## π¬ End-to-end walkthrough