Skip to main content

Documentation Index

Fetch the complete documentation index at: https://developers.autoplay.ai/llms.txt

Use this file to discover all available pages before exploring further.

The finished setup has two parallel processes:
  • listener.py — connects to the Autoplay stream, writes raw actions and LLM-generated summaries to event_data.txt.
  • webhook.py — receives Crisp messages, reads event_data.txt as context, queries an LLM, and sends the reply back as an operator message.
Action notes written to event_data.txt (near real-time):
[0] Action: Page view
     Details: User landed on the Dashboard
     Page:    https://yourapp.com/dashboard

[1] Action: Click element
     Details: User clicked the Reports tab
     Page:    https://yourapp.com/dashboard

[2] Action: Click element
     Details: User hovered over the Monthly Active Users chart
     Page:    https://yourapp.com/reports
Summary note appended after the threshold (default 5 actions):
The user landed on the Dashboard and quickly navigated to the Reports tab,
where they explored the analytics charts. They have not created a project
yet and appear to be in an initial evaluation or onboarding phase.

End-to-end walkthrough


🔗 Step 1 — Create a Crisp AI account

In Crisp, select the Crisp AI option when setting up your inbox.
Note: Keep the Hugo AI option disabled — it conflicts with the custom webhook bot you’ll set up in Step 2.

⚙️ Step 2 — Configure the webhook and plugin

In your Crisp dashboard, go to Settings → Integrations → Webhooks and register your webhook endpoint (you’ll get the URL in Step 3). Make sure the Plugin tier is enabled for your account — it’s required to send operator messages via the API.

🖥️ Step 3 — Set up the webhook server

This FastAPI app receives Crisp messages, reads the event context written by the listener, and replies via the Crisp REST API.

Project structure

.
├── webhook.py        # FastAPI webhook server
└── listener.py       # Autoplay stream listener (Step 4)

Prerequisites

  • Python 3.10+
  • An OpenAI API key
  • uvicorn for serving the FastAPI app
  • (Optional) Cloudflare Tunnel or Ngrok for local development
pip install fastapi httpx openai uvicorn

Configuration

Replace the placeholder values below with your actual credentials. Never commit real secrets to version control — use environment variables or a secrets manager in production.
# Autoplay SDK Config
STREAM_URL = "https://your-stream-url-here"
UNKEY_API_KEY = "your-unkey-api-key"

# Crisp API credentials
CRISP_TOKEN_IDENTIFIER = "your-crisp-token-identifier"
CRISP_TOKEN_KEY = "your-crisp-token-key"
WEBSITE_ID = "your-crisp-website-id"

# Crisp webhook secret — used to verify incoming webhook requests
WEBHOOK_SECRET = "your-webhook-secret"
Security tip: Load these from environment variables using os.getenv() or python-dotenv before deploying.

Code node: send_crisp_message

This helper posts a text message into a Crisp conversation on behalf of an operator (your bot).
import httpx
import base64

async def send_crisp_message(session_id: str, text: str):
    """Send a message back into Crisp as an operator."""
    credentials = base64.b64encode(
        f"{CRISP_TOKEN_IDENTIFIER}:{CRISP_TOKEN_KEY}".encode()
    ).decode()

    url = f"https://api.crisp.chat/v1/website/{WEBSITE_ID}/conversation/{session_id}/message"

    async with httpx.AsyncClient() as client:
        response = await client.post(
            url,
            headers={
                "Authorization": f"Basic {credentials}",
                "X-Crisp-Tier": "plugin",
                "Content-Type": "application/json",
            },
            json={
                "type": "text",
                "from": "operator",
                "origin": "chat",
                "content": text,
            },
        )
        response.raise_for_status()
        return response.json()
Key points:
  • Crisp’s REST API uses HTTP Basic Auth with your token identifier and key Base64-encoded.
  • The X-Crisp-Tier: plugin header is required for plugin-tier integrations.
  • from: "operator" ensures the message appears as a bot/agent reply, not a visitor message.

Code node: get_event_data_from_file

The chatbot is grounded in data from a local text file. Swap this out with a database query, vector search, or API call depending on your use case.
def get_event_data_from_file():
    file_name = "event_data.txt"

    with open(file_name, "r") as file:
        event_data = file.read()
        return event_data

Code node: llm

This function builds a prompt that injects the event data as context, then calls the OpenAI chat completions API.
import openai

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

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

async_openai = openai.AsyncOpenAI()

async def llm(prompt: str) -> str:
    event_data = get_event_data_from_file()
    instructions = (
        "## Current User Activity\n"
        "The following is a real-time record of what this user has been doing "
        "in the last 2 minutes. Use it to give context-aware answers.\n\n"
        f"{event_data}\n\n"
        "---\n"
        "Answer the user's question using the activity above where relevant. "
        "If the activity doesn't help, answer normally. "
        "If you don't know, say so honestly."
    )

    r = await async_openai.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {"role": "system", "content": SYSTEM_PROMPT},
            {
                "role": "user",
                "content": f"{instructions}\n\nUser question: {prompt}",
            },
        ],
        temperature=0.3,
        max_tokens=512,
    )
    return r.choices[0].message.content
Key points:
  • The system prompt teaches the model when and how to use the activity data — not just that it exists.
  • The user-turn context is labelled ## Current User Activity so the model treats it as a named record, not raw noise.
  • max_tokens is raised to 512 to give the model room for numbered steps and follow-up suggestions.
  • temperature=0.3 keeps responses factual. Raise it slightly (e.g. 0.5) if you want warmer, more conversational replies.

Code node: handle_message

Crisp enforces a strict 2-second timeout on webhook delivery. This wrapper lets the HTTP response return immediately while the AI call runs in the background.
import asyncio

async def handle_message(session_id: str, user_message: str):
    """
    Runs in the background so we can return 200 to Crisp immediately.
    Crisp requires a response within 2 seconds — the actual AI call
    can take longer since it runs after we've already replied to Crisp.
    """
    try:
        bot_reply = await llm(user_message)
        await send_crisp_message(session_id, bot_reply)
    except Exception as e:
        print(f"[✗] Failed to handle message for session {session_id}: {e}")

FastAPI webhook endpoint

The main webhook listens for POST requests from Crisp and dispatches the background task.
from fastapi import FastAPI, Request, Query, HTTPException

app = FastAPI()

@app.post("/crisp/hooks")
async def webhook(request: Request, key: str = Query(...)):
    # 1. Verify the secret key
    if key != WEBHOOK_SECRET:
        raise HTTPException(status_code=401, detail="Unauthorized")

    body = await request.json()
    event = body.get("event")
    data = body.get("data", {})

    # 2. Only handle incoming visitor text messages
    #    Checking from == "user" prevents an infinite loop where
    #    your bot replies to its own operator messages
    if (
        event == "message:send"
        and data.get("from") == "user"
        and data.get("type") == "text"
    ):
        session_id = data["session_id"]
        user_message = data["content"]

        # 3. Fire and forget — return 200 to Crisp immediately,
        #    handle the AI call in the background
        asyncio.create_task(handle_message(session_id, user_message))

    # 4. Always return 200, or Crisp marks the delivery as failed
    return {"status": "ok"}


@app.get("/")
async def health():
    return {"status": "running"}
Key points:
  • The webhook secret is passed as a query parameter (?key=...) and verified on every request.
  • Filtering for from == "user" is critical — without it, the bot would respond to its own messages, creating an infinite loop.
  • asyncio.create_task() schedules the AI work without blocking the response.
  • Crisp will retry delivery if it doesn’t receive 200 OK, so the final return {"status": "ok"} must always be reached.

Running locally

Start the server:
uvicorn webhook:app --host 0.0.0.0 --port 3000 --reload
Expose it to the internet for webhook delivery (Crisp needs a public URL):
# Option A — Cloudflare Tunnel
cloudflared tunnel --url http://localhost:3000

# Option B — Ngrok
ngrok http 3000
Register the tunnel URL as your Crisp webhook endpoint, appending your secret:
https://<your-tunnel>.trycloudflare.com/crisp/hooks?key=your-webhook-secret
# or
https://<your-tunnel>.ngrok-free.dev/crisp/hooks?key=YOUR_SECRET_KEY

Complete code — webhook.py

import httpx
import asyncio
from fastapi import FastAPI, Request, Query, HTTPException
import openai

# ─── Config ───────────────────────────────────────────────────────────────────

STREAM_URL = "your-stream-url"
UNKEY_API_KEY = "your-unkey-api-key"

CRISP_TOKEN_IDENTIFIER = "your-crisp-token-identifier"
CRISP_TOKEN_KEY = "your-crisp-token-key"
WEBSITE_ID = "your-crisp-website-id"
WEBHOOK_SECRET = "your-webhook-secret"


# ─── Crisp REST API helper ─────────────────────────────────────────────────────

async def send_crisp_message(session_id: str, text: str):
    """Send a message back into Crisp as an operator."""
    import base64

    credentials = base64.b64encode(
        f"{CRISP_TOKEN_IDENTIFIER}:{CRISP_TOKEN_KEY}".encode()
    ).decode()

    url = f"https://api.crisp.chat/v1/website/{WEBSITE_ID}/conversation/{session_id}/message"

    async with httpx.AsyncClient() as client:
        response = await client.post(
            url,
            headers={
                "Authorization": f"Basic {credentials}",
                "X-Crisp-Tier": "plugin",
                "Content-Type": "application/json",
            },
            json={
                "type": "text",
                "from": "operator",
                "origin": "chat",
                "content": text,
            },
        )
        response.raise_for_status()
        return response.json()


# ─── Chatbot ───────────────────────────────────────────────────────────────────

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

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


def get_event_data_from_file():
    file_name = "event_data.txt"
    with open(file_name, "r") as file:
        return file.read()


async_openai = openai.AsyncOpenAI()


async def llm(prompt: str) -> str:
    event_data = get_event_data_from_file()
    instructions = (
        "## Current User Activity\n"
        "The following is a real-time record of what this user has been doing "
        "in the last 2 minutes. Use it to give context-aware answers.\n\n"
        f"{event_data}\n\n"
        "---\n"
        "Answer the user's question using the activity above where relevant. "
        "If the activity doesn't help, answer normally. "
        "If you don't know, say so honestly."
    )

    r = await async_openai.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {"role": "system", "content": SYSTEM_PROMPT},
            {
                "role": "user",
                "content": f"{instructions}\n\nUser question: {prompt}",
            },
        ],
        temperature=0.3,
        max_tokens=512,
    )
    return r.choices[0].message.content


# ─── Background Task ───────────────────────────────────────────────────────────

async def handle_message(session_id: str, user_message: str):
    try:
        bot_reply = await llm(user_message)
        await send_crisp_message(session_id, bot_reply)
    except Exception as e:
        print(f"[✗] Failed to handle message for session {session_id}: {e}")


# ─── FastAPI App ───────────────────────────────────────────────────────────────

app = FastAPI()


@app.post("/crisp/hooks")
async def webhook(request: Request, key: str = Query(...)):
    if key != WEBHOOK_SECRET:
        raise HTTPException(status_code=401, detail="Unauthorized")

    body = await request.json()
    event = body.get("event")
    data = body.get("data", {})

    if (
        event == "message:send"
        and data.get("from") == "user"
        and data.get("type") == "text"
    ):
        session_id = data["session_id"]
        user_message = data["content"]
        asyncio.create_task(handle_message(session_id, user_message))

    return {"status": "ok"}


@app.get("/")
async def health():
    return {"status": "running"}

# run using: uvicorn webhook:app --host 0.0.0.0 --port 3000 --reload
# expose via: cloudflared tunnel --url http://localhost:3000

🐍 Step 4 — Set up the real-time event listener

This Python script connects to your Autoplay stream, writes structured action data to event_data.txt, and periodically appends an LLM-generated summary.

Prerequisites

  • Python 3.10+
  • An Autoplay SDK account with a stream URL and API key
  • An OpenAI API key
pip install openai httpx autoplay-sdk

Project structure

.
├── listener.py       # This script — the event listener
├── webhook.py        # The webhook server from Step 3
└── event_data.txt    # Auto-created; shared knowledge file consumed by the webhook

Configuration

# Autoplay SDK — stream URL and API key from your Autoplay dashboard
STREAM_URL = "your-autoplay-stream-url"
UNKEY_API_KEY = "your-unkey-api-key"

# Crisp credentials (used if you extend this to write back to Crisp)
CRISP_WEBSITE_ID = "your-crisp-website-id"
CRISP_TOKEN_IDENTIFIER = "your-crisp-token-identifier"
CRISP_TOKEN_KEY = "your-crisp-token-key"

HTTP clients, decorator, and summarizer LLM

Two async clients are initialised at module level: one for Crisp (available for extension), one for OpenAI, plus the api_call decorator and the small llm helper used by the summarizer.
import httpx
import openai

auth = httpx.BasicAuth(username=CRISP_TOKEN_IDENTIFIER, password=CRISP_TOKEN_KEY)

crisp_client = httpx.AsyncClient(
    base_url=f"https://api.crisp.chat/v1/website/{CRISP_WEBSITE_ID}",
    auth=auth,
    headers={"Accept": "application/json"},
)

async_openai = openai.AsyncOpenAI()
A lightweight decorator wraps every external API call to catch and log exceptions without crashing the listener:
def api_call(func):
    async def wrapper(*args, **kwargs):
        try:
            return await func(*args, **kwargs)
        except Exception as e:
            print(f"Error in API call: {e}")
    return wrapper
Thin wrapper around the OpenAI chat completions API, passed into AsyncSessionSummarizer to generate session summaries:
@api_call
async def llm(prompt: str) -> str:
    r = await async_openai.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": prompt}],
        temperature=0.3,
        max_tokens=256,
    )
    return r.choices[0].message.content
Both clients are module-level singletons — created once and reused across all requests, which is the correct pattern for httpx.AsyncClient and openai.AsyncOpenAI. Apply @api_call to any async function that makes a network or I/O call. This keeps the main event loop alive even when individual calls fail. temperature=0.3 keeps summaries factual and deterministic. Increase it if you want more descriptive prose.

Write actions to file

Each incoming batch of actions is appended to event_data.txt. The file is reset if it hasn’t been modified in 2 minutes, which serves as a simple heuristic for detecting a new user session.
import os
import time

@api_call
async def write_actions_to_file(payload: ActionsPayload):
    # Reset file if last modified more than 2 minutes ago (assuming new session)
    if os.path.exists("event_data.txt"):
        last_modified_time = os.path.getmtime("event_data.txt")
        if time.time() - last_modified_time > 2 * 60:
            open("event_data.txt", "w").close()

    filename = "event_data.txt"
    with open(filename, "a") as f:
        for i, action in enumerate(payload.actions):
            f.write(f"[{i}] Action: {action.title}\n")
            f.write(f"     Details: {action.description}\n")
            f.write(f"     Page:    {action.canonical_url}\n")
Note: The 2-minute reset is intentionally simple. For production, consider keying the file by session_id or using a proper store (Redis, SQLite) to handle concurrent users.

Summary writer callbacks

AsyncAgentContextWriter expects two callbacks: one to write actions (which we skip, handling that ourselves above), and one to persist the generated summary.
# We handle action writing ourselves, so this callback is a no-op
async def dummy_write_actions(session_id: str, text: str) -> None:
    pass


@api_call
async def overwrite_summary(session_id: str, summary: str) -> None:
    filename = "event_data.txt"
    with open(filename, "a") as f:
        f.write(f"\n=== SUMMARY ===\n{summary}\n")
from autoplay_sdk import AsyncSessionSummarizer, AsyncAgentContextWriter

summarizer = AsyncSessionSummarizer(llm=llm, threshold=5)

agent_writer = AsyncAgentContextWriter(
    summarizer=summarizer,
    write_actions=dummy_write_actions,
    overwrite_with_summary=overwrite_summary,
    debounce_ms=0,
)
The summary is appended under a clear === SUMMARY === header so any consumer (like the Crisp chatbot) can distinguish raw action logs from synthesised summaries. threshold=5 means the summarizer fires after every 5 actions. Lower it for faster summaries; raise it to reduce LLM calls. debounce_ms=0 disables write debouncing — safe here since writes are cheap file appends.

Action interceptor

The central handler called by the SDK for each incoming action batch. It does two things in sequence: writes raw structured data to file, then forwards the payload to the agent writer for summarisation tracking.
from autoplay_sdk import ActionsPayload

async def handle_actions_interceptor(payload: ActionsPayload):
    # 1. Persist the raw structured action data
    await write_actions_to_file(payload)
    # 2. Forward into the agent_writer to count toward the summarizer threshold
    await agent_writer.add(payload)
import asyncio
from autoplay_sdk import AsyncConnectorClient

async def main():
    async with AsyncConnectorClient(url=STREAM_URL, token=UNKEY_API_KEY) as client:
        client.on_actions(handle_actions_interceptor)

        print("Listening for live website clicks… (Press Ctrl+C to stop)")
        await client.run()


if __name__ == "__main__":
    asyncio.run(main())
Separating raw logging from summarisation means you always have the full action history, regardless of whether the threshold has been reached. The async with context manager ensures the connection is cleanly closed on exit or interruption.

Running the listener

python listener.py
Keep it running as a background process. On a server, use systemd, supervisord, or a process manager like pm2 to ensure it restarts on failure.

Complete code — listener.py

import asyncio
import os
import time

import httpx
import openai
from autoplay_sdk import (
    ActionsPayload,
    AsyncAgentContextWriter,
    AsyncConnectorClient,
    AsyncSessionSummarizer,
)

# ─── Config ───────────────────────────────────────────────────────────────────

STREAM_URL = "your-autoplay-stream-url"
UNKEY_API_KEY = "your-unkey-api-key"

CRISP_WEBSITE_ID = "your-crisp-website-id"
CRISP_TOKEN_IDENTIFIER = "your-crisp-token-identifier"
CRISP_TOKEN_KEY = "your-crisp-token-key"

# ─── Clients ──────────────────────────────────────────────────────────────────

auth = httpx.BasicAuth(username=CRISP_TOKEN_IDENTIFIER, password=CRISP_TOKEN_KEY)

crisp_client = httpx.AsyncClient(
    base_url=f"https://api.crisp.chat/v1/website/{CRISP_WEBSITE_ID}",
    auth=auth,
    headers={"Accept": "application/json"},
)

async_openai = openai.AsyncOpenAI()


def api_call(func):
    async def wrapper(*args, **kwargs):
        try:
            return await func(*args, **kwargs)
        except Exception as e:
            print(f"Error in API call: {e}")
    return wrapper


@api_call
async def llm(prompt: str) -> str:
    r = await async_openai.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": prompt}],
        temperature=0.3,
        max_tokens=256,
    )
    return r.choices[0].message.content


@api_call
async def write_actions_to_file(payload: ActionsPayload):
    if os.path.exists("event_data.txt"):
        last_modified_time = os.path.getmtime("event_data.txt")
        if time.time() - last_modified_time > 2 * 60:
            open("event_data.txt", "w").close()

    filename = "event_data.txt"
    with open(filename, "a") as f:
        for i, action in enumerate(payload.actions):
            f.write(f"[{i}] Action: {action.title}\n")
            f.write(f"     Details: {action.description}\n")
            f.write(f"     Page:    {action.canonical_url}\n")


async def dummy_write_actions(session_id: str, text: str) -> None:
    pass


@api_call
async def overwrite_summary(session_id: str, summary: str) -> None:
    filename = "event_data.txt"
    with open(filename, "a") as f:
        f.write(f"\n=== SUMMARY ===\n{summary}\n")


summarizer = AsyncSessionSummarizer(llm=llm, threshold=5)

agent_writer = AsyncAgentContextWriter(
    summarizer=summarizer,
    write_actions=dummy_write_actions,
    overwrite_with_summary=overwrite_summary,
    debounce_ms=0,
)


async def handle_actions_interceptor(payload: ActionsPayload):
    await write_actions_to_file(payload)
    await agent_writer.add(payload)


async def main():
    async with AsyncConnectorClient(url=STREAM_URL, token=UNKEY_API_KEY) as client:
        client.on_actions(handle_actions_interceptor)
        print("Listening for live website clicks… (Press Ctrl+C to stop)")
        await client.run()


if __name__ == "__main__":
    asyncio.run(main())

🌐 Step 5 — Add the chatbot to your frontend

Copy the Crisp embed snippet from your Crisp dashboard and paste it into your frontend index.html file, just before the closing </body> tag.
<script type="text/javascript">
  window.$crisp=[];
  window.CRISP_WEBSITE_ID="your-crisp-website-id";
  (function(){
    d=document;s=d.createElement("script");
    s.src="https://client.crisp.chat/l.js";
    s.async=1;d.getElementsByTagName("head")[0].appendChild(s);
  })();
</script>

That’s it. Events will start appearing in event_data.txt as soon as the user interacts with your UI. Summaries are generated and appended automatically once the action threshold is reached. The Crisp chatbot will use all of this data to answer visitor questions with real-time context.
Next: Step 2 — Define proactive triggers