Stream live user actions into an Inkeep-aware bridge using the Autoplay SDK, expose assembled context over HTTP, and wire InkeepEmbeddedChat with a pre-loaded intro message.
Use this file to discover all available pages before exploring further.
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 (@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.
What you’ll build:
Frontend autocapture with posthog.identify() + posthog.register({email}).
Product onboarding with onboard_product for webhook + SSE credentials.
PostHog destination forwarding events to your Autoplay connector.
A small FastAPI bridge (AsyncConnectorClient → AsyncContextStore → AsyncSessionSummarizer → AsyncAgentContextWriter) with a /context/{session_id} endpoint.
The Inkeep agents framework running locally (Docker + pnpm dev server).
A project, agent, and sub-agent configured via the Inkeep management API.
An InkeepEmbeddedChat widget that pre-loads the assembled Autoplay context as its introMessage.
An end-to-end check where the chat opens knowing exactly what the user was doing.
Runtime loop: click in app → event lands in bridge context → user opens chat → frontend fetches /context/{session_id} → bridge assembles prompt → InkeepEmbeddedChat opens with grounded introMessage.
🪝 Step 1 — Capture clicks in your web app with PostHog
Install posthog-js and initialize it once on app load.
npm install posthog-js
app/posthog-provider.js — PostHog provider setup (expand to copy)
// 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.
It prints four values — save them. We’ll use all four:
webhook_url — PostHog will POST events here.
webhook_secret — X-PostHog-Secret header value for the destination.
stream_url — the SSE endpoint the bridge subscribes to.
unkey_key — Bearer token for SSE auth.
contact_email is required. It is stored on the connector product row so Autoplay can reach you. Re-registering the same product_id returns 409 unless you pass force_reregister=True (rotates the webhook secret).
Click Test function — expect status 200 in under 200 ms.
Create & enable.
PostHog requires the Webhook URL field on the form even though the Hog source above overrides it. Paste the same webhook_url from Step 2 into both places.
Verify: click around your app, then check destination Logs for successful POSTs.
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:
cd ~/nexus-cloud/bridgeuv add python-dotenv fastapi 'uvicorn[standard]' openai httpx
Why no /reply endpoint? With Inkeep the LLM call happens inside the Inkeep agents framework — your bridge only needs to assemble and return the context string. This makes the bridge lighter and keeps your LLM credentials in one place (the agents framework .env).
Create bridge/.env with two of the four credentials returned by onboard_product plus your OpenAI key for the session summarizer. Map them as follows:
onboard_product field
.env variable
stream_url
STREAM_URL
unkey_key
UNKEY_API_KEY
webhook_url
(used in Step 3 — PostHog destination URL)
webhook_secret
(used in Step 3 — PostHog destination header)
STREAM_URL=https://event-connector-luda.onrender.com/stream/YOUR_PRODUCT_IDUNKEY_API_KEY=<unkey_key from onboard_product output>OPENAI_API_KEY=sk-...# Optional tuning:LLM_MODEL=gpt-4o-miniSUMMARY_THRESHOLD=10LOOKBACK_SECONDS=300MAX_ACTIONS=30
AsyncAgentContextWriter enforces the SDK’s ordering guarantee: the rolling summary is delivered to your destination before the raw actions it replaces are removed. Your agent never sees a blank context window during a swap.
AsyncContextStore keys actions by session_id. The bridge maintains an index between user_id and session_id, and separately tracks which product_id each session belongs to — required when reading from the store.
copilot_server.py session indexing (expand to copy)
_user_sessions: dict[str, list[tuple[str, float]]] = {} # user_id -> [(session_id, ts), ...]_user_emails: dict[str, str] = {}_session_product: dict[str, str] = {} # session_id -> product_id_session_states: dict[str, SessionState] = {}def _index_session(user_id, session_id, email): if not user_id or not session_id: return now = time.time() bucket = _user_sessions.setdefault(user_id, []) bucket[:] = [(s, t) for (s, t) in bucket if s != session_id and now - t < LOOKBACK_SECONDS] bucket.append((session_id, now)) if email: _user_emails[user_id] = emaildef _session_state(session_id): if session_id not in _session_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. _session_states[session_id] = SessionState( interaction_timeout_s=10.0, cooldown_period_s=20.0, ) return _session_states[session_id]
Pass product_id when reading from ContextStore. Since v0.6.7, AsyncContextStore keys buckets by (product_id, session_id) when payloads carry a product_id — which they always do in practice. Calling context_store.get(session_id) without product_id= silently returns an empty string. Track payload.product_id per session on ingest and pass it on read.
5g. Helpers — name from email, recent activity across sessions
copilot_server.py helper functions (expand to copy)
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 Nonedef _user_recent_activity(user_id): sessions = _user_sessions.get(user_id) or [] chunks = [] for session_id, _ts in sorted(sessions, key=lambda x: -x[1])[:3]: product_id = _session_product.get(session_id) text = context_store.get(session_id, product_id=product_id) if text: chunks.append(text) return "\n\n".join(chunks).strip()
5h. The /context/{session_id} endpoint and health check
This bridge does not call the LLM for chat replies — Inkeep handles that. It exposes a single read endpoint: the frontend fetches it before opening the chat widget to build the introMessage.
copilot_server.py endpoints (expand to copy)
@app.get("/healthz")async def healthz(): return { "status": "ok", "users_tracked": len(_user_sessions), "sessions_tracked": sum(len(v) for v in _user_sessions.values()), }@app.get("/context/{session_id}")async def get_context(session_id: str): """Return the assembled Autoplay context for a session. The frontend calls this before mounting InkeepEmbeddedChat so it can pass the activity summary as introMessage. Returns has_activity=False when no events have been seen yet — the widget falls back to its default greeting in that case. """ product_id = _session_product.get(session_id) text = context_store.get(session_id, product_id=product_id) return { "context": text, "has_activity": bool(text.strip()), "session_id": session_id, }
cd ~/nexus-cloud/bridgeuv run uvicorn copilot_server:app --host 0.0.0.0 --port 8787
You should see:
INFO copilot: autoplay stream listening on https://event-connector-luda.onrender.com/stream/YOUR_PRODUCT_IDINFO autoplay_sdk.async_client: autoplay_sdk: connected
Start the backing services (PostgreSQL on :5433, Doltgres on :5435, SpiceDB on :50051):
docker compose up -d
Copy the sample env:
cp .env.example .env
Open .env and set at minimum:
ANTHROPIC_API_KEY=sk-ant-api03-...# — or —# OPENAI_API_KEY=sk-...INKEEP_AGENTS_MANAGE_DATABASE_URL=postgresql://appuser:password@localhost:5435/inkeep_agentsINKEEP_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.
⚙️ 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:
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:
Create sub-agent curl (expand to copy)
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." }'
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.
Create frontend/components/InkeepWidget.tsx.The key pattern here is:
On mount (and whenever contextKey changes), fetch /context/{session_id} from the bridge.
If has_activity is true, build a warm introMessage that leads with what the user was doing.
Pass introMessage to InkeepEmbeddedChat.
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.
frontend/components/InkeepWidget.tsx — embedded chat widget (expand to copy)
"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 = { sessionId: string; userId: string; contextKey?: string;};export function InkeepWidget({ sessionId, userId, contextKey }: Props) { const [introMessage, setIntroMessage] = useState( "Hi! I'm your onboarding assistant. Ask me anything about setting up Nexus Cloud." ); useEffect(() => { if (!sessionId) return; fetch(`${BRIDGE_URL}/context/${encodeURIComponent(sessionId)}`) .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(() => {}); }, [sessionId, contextKey]); return ( <InkeepEmbeddedChat key={contextKey ?? "default"} baseSettings={{ primaryBrandColor: "#6366f1" }} aiChatSettings={{ baseUrl: BASE_URL, appId: APP_ID, introMessage, placeholder: "Ask about any onboarding step…", }} /> );}
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.
Add InkeepWidget to your onboarding page. Pass the PostHog session_id (available from posthog.get_session_id()) as sessionId so the bridge can look up the right activity bucket.
app/onboarding/page.tsx — page with chat panel (expand to copy)
posthog.get_session_id() returns the same $session_id that flows through PostHog → Autoplay → context_store. This is the correct join key. If you use a custom session identifier, make sure it matches the session_id field in your PostHog autocapture events and in posthog.identify().
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.
Click Need help? to open the chat panel.
The widget fetches /context/{session_id} — has_activity is true — and mounts with:
“I can see you’ve been working on the onboarding. What can I help with?”
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.
# Quick end-to-end check without opening the browser:curl "http://localhost:8787/context/YOUR_SESSION_ID"# {"context":"User navigated to /onboarding, clicked 'Test Connection'...","has_activity":true,"session_id":"..."}
In Step 2 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.
has_activity is false. Check (1) bridge is running, (2) PostHog destination is enabled and POSTing, (3) sessionId passed to InkeepWidget matches the PostHog $session_id, (4) context_store.get() was called without product_id — pass _session_product[session_id] on read
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';.
Events not reaching the bridge. Check PostHog destination logs for failed POSTs; also confirm STREAM_URL and UNKEY_API_KEY in bridge/.env match the onboard_product output.
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 webhook_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).
# Terminal 1 — your web appcd ~/nexus-cloud/frontend && npm run dev# Terminal 2 — bridgecd ~/nexus-cloud/bridge && uv run uvicorn copilot_server:app --port 8787# Terminal 3 — Inkeep agents backing servicescd ~/inkeep-agents && docker compose up -d# Terminal 4 — Inkeep agents APIcd ~/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:
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 Autoplay SDK pipeline is identical to every other chatbot 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.
SDK-managed plumbing: reconnects, backpressure, summary ordering, and context assembly are handled for you; the bridge only needs to read and return the assembled string.
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. Feedback shapes the next version of these docs.