Before you begin. You’ll need a Plain workspace with a Live Chat app created (App ID copied) and a Machine User with an API key generated. See Plain’s Machine User docs if you haven’t done that yet.
🎬 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:- User opens a thread in Plain — Plain fires the
onNewThreadcallback in your widget. - Widget POSTs to your webhook — sends
{ customerId, threadId }. - Server fetches live activity — calls the Autoplay SDK for the user’s last 10 in-app actions, scoped to your Product ID.
- Server resolves the Plain customer — queries Plain’s GraphQL API to get the Plain-internal customer ID from the thread.
- 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_productfrom the 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 for the setup flow.🔑 Set your environment variables
Runningonboard_product from the 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.
| 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 |
🔌 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.
plain-chat-webhook.js — expand to copy
plain-chat-webhook.js — expand to copy
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 <script> snippet to your page — no framework required. Replace YOUR_PLAIN_APP_ID with the liveChatApp_... App ID you copied in the prerequisites. The callbacks.onNewThread handler fires the moment a user opens a new thread — it posts the logged-in user’s id and the thread id to the route you just created. It reads the user id off USER_ID, which your server sets before this script runs (next section).
plain-chat-widget.js — expand to copy
plain-chat-widget.js — expand to copy
/public/plain-chat-widget.js) and load it with a plain <script src="/plain-chat-widget.js" defer></script> 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 <script> tag — it safely escapes special characters and inlines null when no user is logged in, so the widget’s if (!uid) return guard skips the fetch.
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
- Set all four environment variables and restart your server
- Log in to your app as a test user
- Interact with your app for a few minutes — visit pages, click buttons — so Autoplay has recorded actions for this user
- Open the Plain chat widget and send a first message (this creates a new thread)
- 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.lengthis0, the user has no Autoplay activity yet — interact with the app first, then open a fresh thread. onNewThreadnot firing? Open the browser console and confirmPlain.init()ran without errors.401 Unauthorized? Re-copyPLAIN_API_KEYfrom Plain → Settings → Machine users.
“No note” = identity mismatch. Confirm the same value in all three:
- the id your activity source identifies the user with,
- the
userIdyour server resolves and sets onUSER_ID, - the
customerIdarriving at/plain-chat-webhook.
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):
@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)
When the Events API is unlocked on your plan, withcreateCustomerEvent for Ari context. Run both in parallel so human agents see the note too:
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
ForcreateCustomerEvent and createThreadEvent, grant these permissions to the Machine User in Plain → Settings → Machine users:
customerEvent:createthreadEvent:createthread:read(to look up the customer from the thread ID)
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 — 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.