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 projectyet and appear to be in an initial evaluation or onboarding phase.
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.
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.
This function builds a prompt that injects the event data as context, then calls the OpenAI chat completions API.
Code: llm() — system prompt, activity context, and OpenAI chat completion
import openaiSYSTEM_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, andunderstand features. Assume some users are seeing the product for the first time.## 💬 How to use the "Current User Activity" recordYou may receive a special record titled "Current User Activity" in theretrieved context. This shows what THIS user has been doing on theplatform in the last 2 minutes — which page they are on and what theyclicked. The activity is scoped to their session, so it reflects onlytheir 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.## 🌐 LanguageRespond 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.
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.
Code: handle_message — background task for LLM + Crisp reply
import asyncioasync 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}")
The main webhook listens for POST requests from Crisp and dispatches the background task.
Code: FastAPI app — /crisp/hooks webhook and health route
from fastapi import FastAPI, Request, Query, HTTPExceptionapp = 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.
import httpximport asynciofrom fastapi import FastAPI, Request, Query, HTTPExceptionimport 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, andunderstand features. Assume some users are seeing the product for the first time.## 💬 How to use the "Current User Activity" recordYou may receive a special record titled "Current User Activity" in theretrieved context. This shows what THIS user has been doing on theplatform in the last 2 minutes — which page they are on and what theyclicked. The activity is scoped to their session, so it reflects onlytheir 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.## 🌐 LanguageRespond 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
This Python script connects to your Autoplay stream, writes structured action data to event_data.txt, and periodically appends an LLM-generated summary.
.├── 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 — listener stream URL, API key, and Crisp credentials
# Autoplay SDK — stream URL and API key from your Autoplay dashboardSTREAM_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"
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.
Code: HTTP clients, api_call decorator, and summarizer LLM helper
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.
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.
Code: write_actions_to_file — append structured actions to event_data.txt
import osimport time@api_callasync 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.
AsyncAgentContextWriter expects two callbacks: one to write actions (which we skip, handling that ourselves above), and one to persist the generated summary.
Code: summary callbacks and AsyncAgentContextWriter (summarizer + threshold)
# We handle action writing ourselves, so this callback is a no-opasync def dummy_write_actions(session_id: str, text: str) -> None: pass@api_callasync 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")
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.
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.
Code: handle_actions_interceptor and AsyncConnectorClient main()
from autoplay_sdk import ActionsPayloadasync 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 asynciofrom autoplay_sdk import AsyncConnectorClientasync 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.
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