> ## Documentation Index
> Fetch the complete documentation index at: https://docs.bagofwords.com/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 Bag of Words 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.

![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 Bag of Words 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:

  <CodeGroup>
    ```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"
    ```
  </CodeGroup>
</Note>

## 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](/channels/oauth-apps) for the complete authorize, callback, token exchange, refresh, trust, and revocation flow.

A minimal browser redirect looks like this:

<CodeGroup>
  ```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)
  ```
</CodeGroup>

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

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](/install#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 → Bag of Words 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.
</Note>

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

***

## 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"]
}
```

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

***

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

<CodeGroup>
  ```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)
  ```
</CodeGroup>

***

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

<CodeGroup>
  ```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
  ```
</CodeGroup>

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

***

## Tiny SSE Parser

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

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

***

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

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

***

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