> ## Documentation Index
> Fetch the complete documentation index at: https://docs.bow.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Embed BOW Chat

> Add a BOW-powered report and streaming completion experience to your product with OAuth

Use the BOW API to add a BOW-powered chat or completion panel to your own product. Your UI creates or reuses a report, streams a completion with `fetch`, and renders Server-Sent Events as they arrive.

This guide is for internal tools, trusted admin panels, and backend-proxied production apps.

> [!NOTE]
>
> To build the chatbot in BOW and embed its ready-made interface with a floating button, follow [Embed a chatbot in your ERP or portal](https://docs.bow.ai/guides/embed-chatbot.md). That path uses Connected applications and the BOW-served embed SDK; OAuth is optional unless your integration also needs API/MCP access or Entra token exchange.

![An OAuth-connected embedded BOW app showing a completed report and its live event stream](https://raw.githubusercontent.com/bagofwords1/bagofwords/codex/oauth-app-coverage/docs/screenshots/pending-changes/embed-bow-chat/oauth-streaming-app.jpg)

***

## What You Need

* A BOW base URL — for example `https://bow.example.com`
* An OAuth app registered with the `app` scope
* An exact callback URL controlled by your application
* Optional data source or agent IDs to attach to the report
* A backend that can hold the user's OAuth tokens and proxy the BOW stream

> [!NOTE]
>
> Do **not** include `/api` in the user-facing base URL. Build the API base internally:
>
> ```ts TypeScript theme={null}
> const bowUrl = inputBowUrl.replace(/\/+$/, "").replace(/\/api$/i, "");
> const apiBase = `${bowUrl}/api`;
> ```
>
> ```python Python theme={null}
> bow_url = input_bow_url.rstrip("/")
> if bow_url.lower().endswith("/api"):
>     bow_url = bow_url[: -len("/api")]
> api_base = f"{bow_url}/api"
> ```

## Set up OAuth

OAuth is the default credential for a user-facing embedded app. Every person signs in to BOW, and the resulting token calls the API with that person's existing organization membership and permissions.

1. In BOW, open **Settings → Channels → OAuth Apps**.
2. Register an app with the **BOW app** access surface.
3. Add your exact callback URL.
4. Copy the client ID.
5. Implement Authorization Code with PKCE `S256`.

See [OAuth Apps](https://docs.bow.ai/channels/oauth-apps.md) for the complete authorize, callback, token exchange, refresh, trust, and revocation flow.

A minimal browser redirect looks like this:

```ts TypeScript theme={null}
const state = crypto.randomUUID();
const verifierBytes = crypto.getRandomValues(new Uint8Array(32));
const verifier = base64url(verifierBytes);
const challengeBytes = await crypto.subtle.digest(
  "SHA-256",
  new TextEncoder().encode(verifier),
);
const challenge = base64url(new Uint8Array(challengeBytes));

sessionStorage.setItem("bow.oauth.state", state);
sessionStorage.setItem("bow.oauth.verifier", verifier);

const authorize = new URL(`${bowUrl}/api/oauth/authorize`);
authorize.search = new URLSearchParams({
  response_type: "code",
  client_id: clientId,
  redirect_uri: callbackUrl,
  scope: "app",
  state,
  code_challenge: challenge,
  code_challenge_method: "S256",
}).toString();

window.location.assign(authorize.toString());

function base64url(bytes: Uint8Array) {
  return btoa(String.fromCharCode(...bytes))
    .replace(/\+/g, "-")
    .replace(/\//g, "_")
    .replace(/=+$/, "");
}
```

```python Python theme={null}
import base64
import hashlib
import secrets
from urllib.parse import urlencode

state = secrets.token_urlsafe(32)
verifier = secrets.token_urlsafe(64)
challenge = (
    base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest())
    .rstrip(b"=")
    .decode()
)

# Keep both server-side. You need them again at the callback.
session["bow_oauth_state"] = state
session["bow_oauth_verifier"] = verifier

authorize = f"{bow_url}/api/oauth/authorize?" + urlencode(
    {
        "response_type": "code",
        "client_id": client_id,
        "redirect_uri": callback_url,
        "scope": "app",
        "state": state,
        "code_challenge": challenge,
        "code_challenge_method": "S256",
    }
)

return redirect(authorize, code=302)
```

> [!WARNING]
>
> In Python, strip the base64 padding with `.rstrip(b"=")`. BOW compares the challenge against an unpadded base64url digest, and `base64.urlsafe_b64encode` pads by default — leaving the `=` produces a valid-looking authorize request that fails later at the token endpoint with `invalid_grant`.

At the callback, verify `state` before exchanging the code. A backend application should perform the exchange on its server and keep the access and refresh tokens in the user's encrypted session. A public browser client can exchange without a client secret, but should keep its short-lived tokens in `sessionStorage`, not persistent storage.

The API examples below assume you have the resulting `bow_oauth_...` access token.

## Local and Entra ID sign-in

Your app does not implement separate local and Microsoft sign-in flows. It always redirects to BOW.

If the user has no BOW session, BOW presents its own sign-in page. The user can enter a local BOW email and password or select a configured provider such as **Sign in with Entra**. BOW preserves the OAuth request across the sign-in round trip and returns the user to your callback.

For BOW deployment configuration, see [Microsoft Entra ID sign-in](https://docs.bow.ai/install.md#for-microsoft-entra-id-azure-ad).

A regular OAuth app shows consent after sign-in. A **Trusted** internal app skips the consent screen and returns automatically; it still requires the user to authenticate when no BOW session exists. If the user selects **Deny**, your callback receives `error=access_denied` with the original `state`, and no token is issued.

## Recommended Architecture

Use a backend-for-frontend for production applications:

```text theme={null}
Browser UI → Your backend → BOW API
```

Your backend should:

* start the OAuth flow and store `state` plus the PKCE verifier
* exchange the authorization code
* keep access and refresh tokens out of browser JavaScript
* enforce your application's own authorization rules
* proxy BOW API requests and stream SSE responses to the browser
* rotate the stored refresh token after every successful refresh

This default architecture requires no BOW CORS setup.

## Browser-only apps and CORS

A browser-only app can use OAuth as a public PKCE client, but direct cross-origin API calls also require a BOW server allowlist.

Set exact origins on the BOW deployment and restart it:

```bash theme={null}
BOW_CORS_ALLOWED_ORIGINS=https://app.example.com,http://localhost:5173
```

Match scheme, host, and port exactly, with no trailing slash. Do not use `*`.

> [!NOTE]
>
> CORS is not involved when your backend calls BOW. Leave `BOW_CORS_ALLOWED_ORIGINS` unset for proxied and same-origin deployments.

## When to use a service account instead

Use a service-account API key only for an unattended integration with no signed-in person, such as a scheduled backend job. Create it under **Settings → Service Accounts** and keep it server-side.

Do not put a personal or service-account API key in browser code.

***

## Minimum Streaming Flow

1. Create a report with the data sources your chat should use.
2. `POST` a streaming completion to that report.
3. Parse SSE frames from the response body.
4. Render assistant text, reasoning, tool progress, and errors from the events.

> [!NOTE]
>
> Reports are the scope for data sources. Attach data sources when creating or updating the report — not in the completion prompt mentions.

***

## Create a Report

Create a report when the user starts a new chat session or when you need a temporary scratch report.

```http theme={null}
POST /api/reports
Authorization: Bearer bow_oauth_...
Content-Type: application/json

{
  "title": "Customer Support Chat",
  "data_sources": ["agent-or-data-source-id"]
}
```

```ts TypeScript theme={null}
async function createReport(apiBase: string, accessToken: string, dataSourceIds: string[]) {
  const response = await fetch(`${apiBase}/reports`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${accessToken}`,
      "Content-Type": "application/json",
      Accept: "application/json",
    },
    body: JSON.stringify({
      title: "Embedded Bow Chat",
      data_sources: dataSourceIds,
    }),
  });

  if (!response.ok) throw new Error(`Create report failed: ${response.status}`);
  return response.json() as Promise<{ id: string }>;
}
```

```python Python theme={null}
import httpx


def create_report(api_base: str, access_token: str, data_source_ids: list[str]) -> dict:
    response = httpx.post(
        f"{api_base}/reports",
        headers={
            "Authorization": f"Bearer {access_token}",
            "Content-Type": "application/json",
            "Accept": "application/json",
        },
        json={
            "title": "Embedded Bow Chat",
            "data_sources": data_source_ids,
        },
    )

    response.raise_for_status()
    return response.json()
```

***

## Stream a Completion

Use `fetch`, not `EventSource`, because the completion stream is a `POST` request with custom headers.

The same endpoint serves streaming and non-streaming responses. It streams when **any** of these is true — send `stream: true` and the `Accept` header together for clarity:

* `stream: true` in the request body
* `Accept: text/event-stream`
* `?stream=true` in the query string

```http theme={null}
POST /api/reports/{report_id}/completions
Authorization: Bearer bow_oauth_...
Content-Type: application/json
Accept: text/event-stream

{
  "prompt": {
    "content": "Show me the top customers this month",
    "mentions": [],
    "mode": "chat"
  },
  "stream": true
}
```

```ts TypeScript theme={null}
async function streamCompletion(params: {
  apiBase: string;
  accessToken: string;
  reportId: string;
  prompt: string;
  signal?: AbortSignal;
  onEvent: (event: BowSseEvent) => void;
}) {
  const response = await fetch(
    `${params.apiBase}/reports/${encodeURIComponent(params.reportId)}/completions`,
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${params.accessToken}`,
        "Content-Type": "application/json",
        Accept: "text/event-stream",
      },
      body: JSON.stringify({
        prompt: { content: params.prompt, mentions: [], mode: "chat" },
        stream: true,
      }),
      signal: params.signal,
    },
  );

  if (!response.ok) {
    const detail = await response.text();
    throw new Error(`Completion failed: ${response.status} ${detail}`);
  }

  if (!response.body) throw new Error("Streaming response body is unavailable.");

  const reader = response.body.getReader();
  const decoder = new TextDecoder();
  const parser = createSseParser(params.onEvent);

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    parser.feed(decoder.decode(value, { stream: true }));
  }

  parser.feed(decoder.decode());
  parser.flush();
}
```

```python Python theme={null}
from urllib.parse import quote

import httpx


def stream_completion(
    api_base: str,
    access_token: str,
    report_id: str,
    prompt: str,
    on_event,
) -> None:
    # timeout=None matters: a run can think for a long stretch between frames,
    # and httpx's default read timeout would abort the stream mid-answer.
    with httpx.Client(timeout=None) as client:
        with client.stream(
            "POST",
            f"{api_base}/reports/{quote(report_id, safe='')}/completions",
            headers={
                "Authorization": f"Bearer {access_token}",
                "Content-Type": "application/json",
                "Accept": "text/event-stream",
            },
            json={
                "prompt": {"content": prompt, "mentions": [], "mode": "chat"},
                "stream": True,
            },
        ) as response:
            if response.status_code >= 400:
                response.read()
                raise RuntimeError(
                    f"Completion failed: {response.status_code} {response.text}"
                )

            parse_sse(response.iter_lines(), on_event)
```

***

## SSE Format

Bow sends standard Server-Sent Events:

```text theme={null}
event: completion.started
data: {"event":"completion.started","data":{"system_completion_id":"..."}}

event: block.delta.token
data: {"event":"block.delta.token","data":{"block_id":"...","field":"content","token":"Hello"}}

data: [DONE]
```

A blank line ends an event. Multiple `data:` lines belong to the same event and should be joined with `\n`.

The `data:` payload is an envelope — parse it like this:

```ts TypeScript theme={null}
const parsed = JSON.parse(dataString);
const payload = parsed.data ?? parsed;
```

```python Python theme={null}
parsed = json.loads(data_string)
payload = parsed.get("data") if isinstance(parsed, dict) else None
if payload is None:
    payload = parsed
```

`data: [DONE]` means the stream is complete.

***

## Tiny SSE Parser

```ts TypeScript theme={null}
type BowSseEvent = {
  event: string;
  payload: unknown;
  raw: string;
  receivedAt: string;
  done?: boolean;
  malformed?: boolean;
};

function createSseParser(onEvent: (event: BowSseEvent) => void) {
  let buffer = "";
  let eventName = "message";
  let dataLines: string[] = [];
  let rawLines: string[] = [];

  function dispatch() {
    if (dataLines.length === 0) {
      // Comment-only frames carry no event. BOW sends ": ping" heartbeats
      // during quiet stretches of a run, and dispatching those would surface
      // a bogus malformed event on every heartbeat.
      eventName = "message";
      rawLines = [];
      return;
    }

    const dataString = dataLines.join("\n");
    const raw = `${rawLines.join("\n")}\n\n`;
    const receivedAt = new Date().toISOString();

    if (dataString === "[DONE]") {
      onEvent({ event: "[DONE]", payload: "[DONE]", raw, receivedAt, done: true });
    } else {
      try {
        const parsed = JSON.parse(dataString);
        onEvent({ event: eventName, payload: parsed.data ?? parsed, raw, receivedAt });
      } catch (error) {
        onEvent({
          event: eventName,
          payload: { error: String(error), data: dataString },
          raw,
          receivedAt,
          malformed: true,
        });
      }
    }

    eventName = "message";
    dataLines = [];
    rawLines = [];
  }

  return {
    feed(chunk: string) {
      buffer += chunk;

      while (true) {
        const newlineIndex = buffer.search(/\r\n|\n|\r/);
        if (newlineIndex === -1) break;

        const line = buffer.slice(0, newlineIndex);
        const newline = buffer.slice(newlineIndex).match(/^\r\n|\n|\r/)?.[0] ?? "\n";
        buffer = buffer.slice(newlineIndex + newline.length);

        if (line === "") { dispatch(); continue; }

        rawLines.push(line);
        if (line.startsWith(":")) continue;

        const colonIndex = line.indexOf(":");
        const field = colonIndex === -1 ? line : line.slice(0, colonIndex);
        const value = colonIndex === -1 ? "" : line.slice(colonIndex + 1).replace(/^ /, "");

        if (field === "event") eventName = value || "message";
        if (field === "data") dataLines.push(value);
      }
    },
    flush() {
      if (buffer) { rawLines.push(buffer); buffer = ""; }
      dispatch();
    },
  };
}
```

```python Python theme={null}
import json
from datetime import datetime, timezone


def parse_sse(lines, on_event) -> None:
    event_name = "message"
    data_lines: list[str] = []
    raw_lines: list[str] = []

    def dispatch() -> None:
        nonlocal event_name, data_lines, raw_lines

        if not data_lines:
            # Comment-only frames carry no event. BOW sends ": ping" heartbeats
            # during quiet stretches of a run, and dispatching those would
            # surface a bogus malformed event on every heartbeat.
            event_name = "message"
            raw_lines = []
            return

        data_string = "\n".join(data_lines)
        raw = "\n".join(raw_lines) + "\n\n"
        received_at = datetime.now(timezone.utc).isoformat()

        if data_string == "[DONE]":
            on_event({
                "event": "[DONE]",
                "payload": "[DONE]",
                "raw": raw,
                "received_at": received_at,
                "done": True,
            })
        else:
            try:
                parsed = json.loads(data_string)
                payload = parsed.get("data") if isinstance(parsed, dict) else None
                if payload is None:
                    payload = parsed
                on_event({
                    "event": event_name,
                    "payload": payload,
                    "raw": raw,
                    "received_at": received_at,
                })
            except json.JSONDecodeError as error:
                on_event({
                    "event": event_name,
                    "payload": {"error": str(error), "data": data_string},
                    "raw": raw,
                    "received_at": received_at,
                    "malformed": True,
                })

        event_name = "message"
        data_lines = []
        raw_lines = []

    for line in lines:
        if line == "":
            dispatch()
            continue

        raw_lines.append(line)
        if line.startswith(":"):
            continue

        field, _, value = line.partition(":")
        if value.startswith(" "):
            value = value[1:]

        if field == "event":
            event_name = value or "message"
        elif field == "data":
            data_lines.append(value)

    # Flush a trailing frame that arrived without a final blank line.
    dispatch()
```

> [!NOTE]
>
> The Python version is shorter because `httpx`'s `iter_lines()` already handles chunk reassembly and `\r\n` line endings, and yields the blank separator lines the framing depends on. The TypeScript version buffers bytes itself, so it has to do that work by hand.

***

## Event Reference

Handle unknown events gracefully — the API may add events over time. A minimal chat only needs `completion.started`, `block.upsert`, `block.delta.token`, `block.delta.text`, the `tool.*` events, and `completion.finished`; the rest are available when you want to render more of the run.

Every frame carries the same envelope — `event`, `data`, `timestamp`, `completion_id`, `agent_execution_id`, `seq` — so `seq` gives you a stable ordering within a run.

### Run lifecycle

| Event | Meaning | UI suggestion |
| - | - | - |
| `completion.started` | The run has started. `data` carries `system_completion_id`, `user_prompt`, and the resolved `model` | Mark the chat as streaming and store `system_completion_id` — you need it to resume or stop the run |
| `completion.resumed` | Sent first on a re-attached stream instead of `completion.started` | Treat exactly like `completion.started` |
| `instructions.context` | The instructions loaded into this run's context | Optional; useful for a "what did the agent know" debug panel |
| `completion.follow_ups` | Suggested next questions, sent after the answer | Render as clickable chips |
| `completion.finished` | The run finished. `data.status` is `success`, `error`, or `stopped` | Mark the chat done. **May arrive more than once** for a single run — make this handler idempotent and ignore repeats |
| `completion.error` | The run failed at the completion level | Stop streaming and show the error message |
| `llm.error` | The LLM provider or model call failed | Stop streaming and show the error message |
| `[DONE]` | The SSE stream ended | Close the stream and re-enable input |

### Text and blocks

| Event | Meaning | UI suggestion |
| - | - | - |
| `block.upsert` | A block was created or updated. `data.block` holds the full block, including `title`, `icon`, and `status` | Insert or update the matching assistant, reasoning, or tool block |
| `block.delta.token` | A token-level delta. Always branch on `data.field` — `content` is the visible answer, `reasoning` is the thinking trace | Append `data.token` to that field |
| `block.delta.text` | A full replacement for a block field | Replace the field with `data.text` |
| `block.delta.text.complete` | That field is finished streaming | Stop any typing indicator on the block |
| `block.delta.artifact` | Incremental changes to a chart or table attached to the block | Optional; re-render the artifact preview |
| `decision.partial` | An intermediate reasoning/action decision | Show provisional reasoning or the planned tool call |
| `decision.final` | The decision was finalized. `data.metrics` carries timings and token usage | Mark the decision block complete |

### Tools and artifacts

| Event | Meaning | UI suggestion |
| - | - | - |
| `tool.started` | A tool call started (`tool_name`, `arguments`, `tool_call_id`, `block_id`) | Show a compact running tool row |
| `tool.progress` | Progress within a tool call — expect many per call | Update the tool row summary |
| `tool.finished` | A tool call finished (`status`, `result_summary`, `result_json`) | Mark the row success/error; keep result JSON behind a disclosure |
| `query.created` | The agent created a saved query on the report | Optional; link to it |
| `visualization.created` / `visualization.updated` | A chart or table was created, then filled in once the query ran | Optional; render the visualization inline |

![The embedded app rendering successful create data and create artifact tool calls alongside raw execution events](https://raw.githubusercontent.com/bagofwords1/bagofwords/codex/oauth-app-coverage/docs/screenshots/pending-changes/embed-bow-chat/oauth-streaming-tools.jpg)

***

## Rendering a Chat UI

> [!NOTE]
>
> This section stays in TypeScript whatever your backend is written in. With a backend-for-frontend, your Python service parses the BOW stream and forwards events to the browser, where this reducer runs.

Keep the UI reducer small:

* Store `status`: `idle`, `streaming`, `success`, or `error`
* Store blocks by `id`
* Append `block.delta.token` for assistant text
* Replace text on `block.delta.text`
* Show reasoning inside a collapsible disclosure
* Show tools as compact rows with name, status, and summary
* Keep raw JSON behind a disclosure for tool payloads
* Keep the raw SSE log available in a debug tab

```ts theme={null}
function applyBowEvent(state: ChatState, event: BowSseEvent) {
  const payload = event.payload as Record<string, any>;

  switch (event.event) {
    case "completion.started":
      state.status = "streaming";
      state.systemCompletionId = payload.system_completion_id;
      break;

    case "block.delta.token": {
      const blockId = payload.block_id ?? "streaming";
      const block = state.blocks[blockId] ??= { content: "" };
      if (payload.field === "content") block.content += payload.token ?? "";
      if (payload.field === "reasoning") block.reasoning = `${block.reasoning ?? ""}${payload.token ?? ""}`;
      break;
    }

    case "tool.started":
    case "tool.progress":
    case "tool.finished":
      state.tools[payload.tool_call_id ?? payload.tool_name ?? "tool"] = payload;
      break;

    case "completion.error":
    case "llm.error":
      state.status = "error";
      state.error = payload.message ?? payload.error ?? JSON.stringify(payload);
      break;

    // Both can arrive more than once — keep this branch idempotent.
    case "completion.finished":
      if (payload.status === "error") state.status = "error";
      else if (state.status !== "error") state.status = payload.status ?? "success";
      break;

    case "[DONE]":
      if (state.status === "streaming") state.status = "success";
      break;
  }
}
```

***

## Resume and Stop a Run

A dropped connection does not stop the agent — the run continues server-side. Two endpoints cover the rest of the lifecycle.

**Re-attach to a run in progress** (page refresh, network drop, a second tab). Pass the `system_completion_id` you stored from `completion.started`. The endpoint is idempotent and side-effect free, so it is safe to retry with backoff. It replays the run's blocks, then continues live:

```http theme={null}
GET /api/reports/{report_id}/completions/{system_completion_id}/stream
Authorization: Bearer bow_oauth_...
Accept: text/event-stream
```

**Actually stop a run.** `AbortController` only closes your side of the socket; the agent keeps working and keeps spending tokens. To cancel the run itself, call:

```http theme={null}
POST /api/completions/{system_completion_id}/sigkill
Authorization: Bearer bow_oauth_...
```

Wire your Stop button to both: `sigkill` to end the run, `AbortController` to release the reader. The run then finishes with `status: "stopped"`.

***

## Non-Streaming and History

Omit the streaming triggers and the same `POST` returns JSON once the run completes, which suits scheduled jobs and server-side summaries. Add `?background=true` to return immediately and let the run continue in the background.

Sending a second prompt while a run is still going starts a **second concurrent run** on the report. If you want the chat to behave like one turn at a time instead, send `"queue": true` — the prompt is stored with `status: "queued"` and starts automatically when the current run finishes. Queued prompts never stream; drop one with `DELETE /api/completions/{completion_id}/queued` while it is still queued.

Both that response and `GET /api/reports/{report_id}/completions` return the **whole conversation envelope**, not a single completion:

```json theme={null}
{
  "report_id": "...",
  "completions": [{ "id": "...", "role": "system", "status": "success", "completion_blocks": [] }],
  "latest_completion": "...",
  "has_more": false,
  "next_before": null,
  "total_completions": 4
}
```

Read the answer from the last `role: "system"` entry. For history, page with `?limit=` and `?before=` (an ISO timestamp cursor) and follow `has_more` / `next_before`.

***

## Optional API Requests

**Validate the OAuth session:**

```http theme={null}
GET /api/users/whoami
Authorization: Bearer bow_oauth_...
```

**Load data sources or agents for a picker:**

```http theme={null}
GET /api/mentions/available?categories=data_sources
Authorization: Bearer bow_oauth_...
```

**Update a report when the user changes selected data sources:**

```http theme={null}
PUT /api/reports/{report_id}
Authorization: Bearer bow_oauth_...
Content-Type: application/json

{
  "data_sources": ["agent-or-data-source-id"]
}
```

***

## Error Handling

| Status | Meaning |
| - | - |
| `400` | Invalid request, or no default LLM model configured — *"No default LLM model configured. Please go to Settings > LLM and set a default model."* |
| `401` | Missing, invalid, expired, or revoked OAuth access token |
| `403` | The signed-in user does not have permission for this resource |
| `404` | Report not found or not visible to the signed-in user |
| Network / CORS | The browser could not reach BOW, or a browser-only app's origin is not in `BOW_CORS_ALLOWED_ORIGINS` |
| Abort | User stopped the stream — treat as controlled cancellation, not a crash |
| Malformed SSE | Keep the raw frame and show a debug-friendly parse error |

> [!WARNING]
>
> Failures **after** the stream opens do not change the HTTP status — the response is already `200`. A run that fails mid-flight reports it as a `completion.error` or `llm.error` frame, or as `completion.finished` with `status: "error"`. Handle those events, not just `response.ok`.

***

## Copy-Paste cURL with an OAuth token

```bash theme={null}
BOW_URL='https://bow.example.com'
# Obtain this from the OAuth callback/token exchange above.
BOW_ACCESS_TOKEN='<user-oauth-access-token>'
API_BASE="$BOW_URL/api"

REPORT_ID="$(curl -sS -X POST "$API_BASE/reports" \
  -H "Authorization: Bearer $BOW_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  --data-raw '{
    "title": "Embedded Bow Chat",
    "data_sources": []
  }' | jq -r '.id')"

curl -N -X POST "$API_BASE/reports/$REPORT_ID/completions" \
  -H "Authorization: Bearer $BOW_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: text/event-stream" \
  --data-raw '{
    "prompt": {
      "content": "Show me a summary of this report",
      "mentions": [],
      "mode": "chat"
    },
    "stream": true
  }'
```

***

## Production Checklist

* Register an OAuth app with the `app` scope and exact callback URLs
* Use Authorization Code with PKCE `S256`; verify `state` at the callback
* Keep OAuth tokens server-side behind a backend proxy
* Replace the stored refresh token after every refresh
* Set `BOW_CORS_ALLOWED_ORIGINS` only if browser JavaScript calls BOW directly
* Wire the Stop button to `sigkill` **and** `AbortController` — aborting alone leaves the run going
* Persist the report ID for the conversation and the `system_completion_id` for the run in flight, so a refresh can re-attach
* Attach data sources through report creation or update
* Keep `mentions: []` unless your integration intentionally supports Bow prompt mentions
* Treat `completion.finished` as idempotent — it can arrive more than once
* Log raw SSE frames for support and debugging
* Render unknown future events without crashing


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.