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

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

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

<Note>
  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.
</Note>

***

**What you'll build:**

1. Frontend autocapture with `posthog.identify()` + `posthog.register({email})`.
2. Product onboarding with `onboard_product` for ingest + MCP 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`.

<Note>
  **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.
</Note>

## 🪝 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
```

<AccordionGroup>
  <Accordion title="app/posthog-provider.js — PostHog provider setup (expand to copy)">
    ```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;
    }
    ```
  </Accordion>
</AccordionGroup>

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.

<Warning>
  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.
</Warning>

**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

async def main() -> None:
    result = await onboard_product(
        "YOUR_AUTOPLAY_PRODUCT_ID",
        contact_email="you@yourcompany.com",
        print_operator_summary=True,
    )
    print(result)

asyncio.run(main())
```

Run it once:

```bash theme={null}
uv run python register_product.py
```

It prints six values — save them. We'll use all six:

* `product_id` — your product's id (echoes back the value you registered with).
* `provider` — `posthog`.
* `ingest_url` — PostHog will POST events here (e.g. `https://connector.autoplay.ai/ingest/YOUR_PRODUCT_ID`).
* `ingest_secret` — `X-PostHog-Secret` header value for the destination.
* `mcp_url` — always `https://mcp.autoplay.ai/mcp`.
* `mcp_key` — Bearer token used both for the connector's REST read and for an MCP tool call, if you ever add one.

<Note>
  **`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=True`, in which case `ingest_secret` rotates — update the PostHog destination in Step 3 to match.
</Note>

***

## 🔗 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 `<INGEST_SECRET>` and `<PRODUCT_ID>`).

<AccordionGroup>
  <Accordion title="PostHog destination — HogQL forwarding script (expand to copy)">
    ```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}')
    }
    ```
  </Accordion>
</AccordionGroup>

6. Click **Test function** — expect status 200 in under 200 ms.
7. **Create & enable.**

<Warning>
  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.
</Warning>

**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
```

<Note>
  **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.
</Note>

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=<mcp_key from onboard_product output>
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

<AccordionGroup>
  <Accordion title="copilot_server.py imports and config (expand to copy)">
    ```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"))
    ```
  </Accordion>
</AccordionGroup>

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

<AccordionGroup>
  <Accordion title="copilot_server.py — pull_live_activity helper (expand to copy)">
    ```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)
    ```
  </Accordion>
</AccordionGroup>

<Note>
  `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.
</Note>

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

<AccordionGroup>
  <Accordion title="copilot_server.py endpoints (expand to copy)">
    ```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,
        }
    ```
  </Accordion>
</AccordionGroup>

### 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
```

<Warning>
  `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.
</Warning>

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:**

<AccordionGroup>
  <Accordion title="Create agent curl (expand to copy)">
    ```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."
      }'
    ```
  </Accordion>
</AccordionGroup>

**Create the sub-agent:**

<AccordionGroup>
  <Accordion title="Create sub-agent curl (expand to copy)">
    ```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."
      }'
    ```
  </Accordion>
</AccordionGroup>

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

<Note>
  **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`.
</Note>

***

## 💬 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.

<AccordionGroup>
  <Accordion title="frontend/components/InkeepWidget.tsx — embedded chat widget (expand to copy)">
    ```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
          key={contextKey ?? "default"}
          baseSettings={{ primaryBrandColor: "#6366f1" }}
          aiChatSettings={{
            baseUrl: BASE_URL,
            appId: APP_ID,
            introMessage,
            placeholder: "Ask about any onboarding step…",
          }}
        />
      );
    }
    ```
  </Accordion>
</AccordionGroup>

### `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.                                                                                                                                                                  |

<Note>
  **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.
</Note>

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

<AccordionGroup>
  <Accordion title="app/onboarding/page.tsx — page with chat panel (expand to copy)">
    ```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 && (
            <button onClick={() => setChatOpen(true)}>
              Need help?
            </button>
          )}

          {/* Chat panel */}
          {chatOpen && userId && (
            <InkeepWidget userId={userId} />
          )}
        </>
      );
    }
    ```
  </Accordion>
</AccordionGroup>

<Note>
  `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()`.
</Note>

***

## ✅ 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`, or the product was re-registered with `force=True` (which rotates `ingest_secret` but **not** `mcp_key` — 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 `product_id` you registered with; 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.
