# Web SDK (/api-reference/web-sdk)



The **Runbear React SDK** (`@runbear-io/react`) embeds a Runbear agent as a chat widget inside your
own web app — a complete chat UI, streaming responses, and conversation history — talking to the
same `/v1` API documented in the [OpenAPI reference](https://api.runbear.io/v1/docs).

This page covers how the widget authenticates. For the exhaustive option, event and type tables, see
the [Runbear SDK Docs](https://app.notion.com/p/runbearinc/Runbear-SDK-Docs-3a730451bece800f8f9dd6393ae9e3b7).

<Callout type="info" title="Private package">
  The SDK is published to GitHub Packages, so installing it needs a GitHub token with `read:packages&#x60;.
  Session mode requires **`@runbear-io/react` 0.2.0 or newer**.
</Callout>

## Install [#install]

Add the registry to `.npmrc`:

```
@runbear-io:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}
```

Then add the dependency:

```json
{
  "dependencies": {
    "@runbear-io/react": "^0.2.0"
  }
}
```

## Quick start [#quick-start]

```tsx
import Runbear from "@runbear-io/react"
import { useEffect, useRef } from "react"

export function AgentWidget() {
  const containerRef = useRef<HTMLDivElement>(null)

  useEffect(() => {
    if (!containerRef.current) return

    const client = new Runbear({
      assistantId: "0fade940-133f-49e6-bf4b-8f662186479b",
      auth: {
        mode: "session",
        fetchSessionToken: async ({ resumeToken, threadId, signal }) => {
          const res = await fetch("/api/runbear/session", {
            method: "POST",
            headers: { "content-type": "application/json" },
            credentials: "include", // your own session cookie
            body: JSON.stringify({ resumeToken, threadId }),
            signal,
          })
          if (!res.ok) throw new Error(`session endpoint failed: ${res.status}`)
          return await res.json()
        },
      },
    })

    const chat = client.createChat()
    chat.mount(containerRef.current)

    return () => chat.destroy()
  }, [])

  return <div ref={containerRef} style={{ width: "100%", height: "100vh" }} />
}
```

`/api/runbear/session` is **your** endpoint — the next section is its full implementation.

## Authentication [#authentication]

A Runbear API key is an **organization-level credential**: anyone who can read it can call your whole
account. The SDK runs in the browser, so the key must never be shipped in public browser JavaScript.
There are three modes; which one fits depends on where you want the credential to live and whether
your server needs to see every message.

| Mode      | Where the credential lives                                 | Who relays the chat                                     | Use it when                                                                                      |
| --------- | ---------------------------------------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| `session` | A short-lived, thread-bound session pass your server mints | Nobody — the browser talks to `api.runbear.io` directly | You want your server out of the per-message path                                                 |
| `proxy`   | Your server; the browser sends no `Authorization` header   | Your server relays every message                        | You must inspect, log or redact every turn, or you need file attachments or suggested follow-ups |
| `direct`  | The org API key, in the browser                            | Nobody                                                  | Local prototyping and fully internal tools only                                                  |

### Session mode [#session-mode]

Your backend mints a short-lived **session pass** for each visitor and hands it to the widget. The
browser then talks to `api.runbear.io` **directly** — your server is out of the per-message path
entirely. The org API key never leaves your server, and a stolen pass is worth one conversation for
a few minutes.

```
Browser (SDK, holds a pass) ──────────────▶ api.runbear.io   (every message)
        │
        ├── at session start ──▶ your backend ──▶ POST /v1/sessions          (org API key)
        └── on each renewal  ──▶ your backend ──▶ POST /v1/sessions/refresh  (org API key)
```

What session mode gives you:

* Your server is out of the **per-message** path: it is called once to mint the session, then again
  on each renewal, instead of on every message. It also holds **zero** streaming connections, where
  a proxy holds one open stream for every in-flight message.
* One less network hop on every message.
* Each visitor is isolated to their own conversation with one agent. A proxy authenticates every
  visitor with the same org key and cannot express that.

A session pass is bound at mint time to **one agent and one thread**. It cannot be widened, and it
cannot mint another pass.

### Proxy mode [#proxy-mode]

Point `baseUrl` at an endpoint on your own backend. The SDK sends requests there with no
`Authorization` header; your server forwards them to `https://api.runbear.io` and attaches the
bearer key server-side.

```ts
const client = new Runbear({
  assistantId: "…",
  auth: { mode: "proxy", baseUrl: "https://app.example.com/api/runbear" },
})
```

Proxy mode is fully supported. It is the right choice when you want every message to pass through
your own logging, redaction, or compliance layer, when you inject server-side context per request,
or when you need the features session mode does not cover in v1 (see
[What a session pass can do](#what-a-session-pass-can-do)).

### Direct mode (development only) [#direct-mode-development-only]

Pass an API key and the SDK calls `api.runbear.io` straight from the browser.

```ts
const client = new Runbear({
  assistantId: "…",
  auth: { mode: "direct", apiKey: "<your Runbear API key>" }, // exposed in the browser
})
```

<Callout type="warn" title="Never ship an org API key to a browser">
  Direct mode is for local prototyping and fully internal tools whose bundle is not public. For
  anything customer-facing use session mode, or proxy mode.
</Callout>

## Your session endpoint [#your-session-endpoint]

This is the one piece of code you must write, and the only place your visitors are authenticated.

<Callout type="warn" title="Authenticate the caller">
  Runbear cannot authenticate your visitor for you: the org API key identifies **you**, not them. An
  unauthenticated endpoint here hands anyone on the internet a session on your account.
</Callout>

```ts
import type { SessionCredentials } from "@runbear-io/react"

const RUNBEAR_API = "https://api.runbear.io"

/** The nested shape Runbear returns. Mirrors the `SessionCredentials` OpenAPI component. */
interface RunbearSessionResponse {
  session: { id: string; assistant_id: string; thread_id: string; expiresAt: string }
  pass: { token: string; expiresAt: string; expiresInSeconds: number }
  resumeToken?: { token: string; expiresAt: string; expiresInSeconds: number }
}

// POST /api/runbear/session — YOUR server, YOUR auth
export async function POST(req: Request): Promise<Response> {
  // 1. REQUIRED: authenticate the caller with your own session / cookie / JWT.
  const user = await getSessionUser(req)
  if (!user) return Response.json({ error: "unauthorized" }, { status: 401 })

  const body: { resumeToken?: string; threadId?: string } = await req.json().catch(() => ({}))

  // 2. Mint (no resumeToken) or refresh (resumeToken present). Two routes, never one.
  const isRefresh = typeof body.resumeToken === "string" && body.resumeToken.length > 0
  const upstreamUrl = new URL(isRefresh ? "/v1/sessions/refresh" : "/v1/sessions", RUNBEAR_API)

  // `endUser` binds the resume token to THIS visitor. Send the same value on
  // every call for that visitor. Use your stable user id — never an email.
  const upstreamBody = isRefresh
    ? { resumeToken: body.resumeToken, endUser: user.id }
    : {
        assistant_id: process.env.RUNBEAR_ASSISTANT_ID,
        endUser: user.id,
        ...(body.threadId ? { thread_id: body.threadId } : {}),
      }

  const res = await fetch(upstreamUrl, {
    method: "POST",
    headers: {
      "content-type": "application/json",
      // Server-only. NEVER return this value to the browser.
      authorization: `Bearer ${process.env.RUNBEAR_API_KEY}`,
    },
    body: JSON.stringify(upstreamBody),
  })

  if (!res.ok) {
    // A 400 `resume_token_expired` on refresh is the normal end of a session:
    // drop the stored resume token and let the widget mint a fresh session on
    // the next page load. (`resume_token_invalid` is the same 400 with a
    // different `code`; treat both the same way.)
    return Response.json({ error: "session_unavailable" }, { status: 502 })
  }

  const upstream: RunbearSessionResponse = await res.json()

  // 3. THE MAPPING. Runbear's response is nested; the SDK's credential is flat.
  const credentials: SessionCredentials = {
    pass: upstream.pass.token,
    threadId: upstream.session.thread_id,
    sessionId: upstream.session.id,
    expiresIn: upstream.pass.expiresInSeconds,
    ...(upstream.resumeToken ? { resumeToken: upstream.resumeToken.token } : {}),
  }

  return Response.json(credentials)
}
```

<Callout type="warn" title="Do not return Runbear's response body unchanged">
  `return new Response(await res.text())` is the most common mistake. Runbear's response is nested and
  the SDK's credential is flat, so every field the widget reads would be `undefined` and the widget
  would never become interactive. Perform the mapping above.
</Callout>

| Runbear response                                                                                                       | SDK `SessionCredentials` | Notes                                                                                                                                                             |
| ---------------------------------------------------------------------------------------------------------------------- | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `pass.token`                                                                                                           | `pass`                   | Required. The browser credential.                                                                                                                                 |
| `session.thread_id`                                                                                                    | `threadId`               | Required. The one thread this pass may read and run.                                                                                                              |
| `session.id`                                                                                                           | `sessionId`              | Required. Log it — it is the join key across the session's requests.                                                                                              |
| `pass.expiresInSeconds`                                                                                                | `expiresIn`              | Required. Seconds, measured by the server.                                                                                                                        |
| `resumeToken.token`                                                                                                    | `resumeToken`            | Optional. Present on mint, absent on every refresh.                                                                                                               |
| `pass.expiresAt`, `resumeToken.expiresAt`, `resumeToken.expiresInSeconds`, `session.assistant_id`, `session.expiresAt` | *not forwarded*          | Server-side bookkeeping. Expiry instants are deliberately not sent to the browser — comparing them to a browser clock is the failure `expiresIn` exists to avoid. |

**Two credentials, two jobs.**

| Credential    | Current default lifetime                             | What it can do                                         | Where it lives                             |
| ------------- | ---------------------------------------------------- | ------------------------------------------------------ | ------------------------------------------ |
| `pass`        | 15 minutes                                           | Read and run exactly one thread with exactly one agent | The browser, in the `Authorization` header |
| `resumeToken` | 24 hours when you send `endUser`; 2 hours without it | Obtain a fresh pass. &#x2A;*It can never chat.**       | Your server, or the SDK's storage          |

The resume token's expiry is the session's absolute ceiling. Refreshing issues a new pass but never
moves that instant; when it passes, mint a new session. Treat both lifetimes as current defaults
read from the response (`expiresInSeconds`), not as guarantees — never hardcode them.

<Callout type="info" title="Sizing your session endpoint">
  A pass is short-lived, so **a long conversation renews repeatedly**. The SDK refreshes shortly
  before each pass expires — with the current 15-minute pass, a little under once a quarter-hour — for
  as long as the visitor keeps the widget open, and every one of those refreshes goes through your
  endpoint. Size it for *one mint per visitor plus one renewal per quarter-hour of open widget time*,
  not for a single call per visitor. That is still far below proxy mode, which sees every message and
  holds a stream open for every reply, but it is not one call and never was.
</Callout>

**Other things this endpoint owns:**

* **The kill switch is yours.** To end a visitor's session, stop returning credentials. Their current
  pass expires within minutes and cannot be renewed.
* **Never return the org API key** in this response, in any field, for any reason.
* **`endUser` is your stable user id**, never an email address. Runbear salts and hashes it
  server-side and binds the resume token to it; every later refresh must then present the
  byte-identical value, or the refresh is rejected. Omit it only for genuinely anonymous widgets —
  the resume token is then a pure bearer credential and is issued with a materially shorter lifetime
  (2 hours instead of 24).

## Migrating from proxy mode [#migrating-from-proxy-mode]

The whole migration is: delete the catch-all forwarder, add one route, change the client options.

**Before — your server forwards every message.**

```ts
// /api/runbear/[...path]  — one request per message, one open stream per reply
const upstream = new URL(path, "https://api.runbear.io")
const res = await fetch(upstream, {
  method: req.method,
  headers: { ...forwardedHeaders, authorization: `Bearer ${process.env.RUNBEAR_API_KEY}` },
  body: req.body,
})
```

**After — your server mints once per visitor.** Delete the route above and add the single
`/api/runbear/session` route from [Your session endpoint](#your-session-endpoint).

**Before — browser.**

```ts
const client = new Runbear({
  assistantId: "…",
  baseUrl: "https://app.example.com/api/runbear",
})
```

**After — browser.**

```ts
const client = new Runbear({
  assistantId: "…",
  auth: {
    mode: "session",
    fetchSessionToken: async ({ resumeToken, threadId, signal }) => {
      const res = await fetch("/api/runbear/session", {
        method: "POST",
        headers: { "content-type": "application/json" },
        credentials: "include",
        body: JSON.stringify({ resumeToken, threadId }),
        signal,
      })
      if (!res.ok) throw new Error(`session endpoint failed: ${res.status}`)
      return await res.json()
    },
  },
})
```

**Checklist**

1. Upgrade to `@runbear-io/react` 0.2.0 or newer.
2. Add the session route; keep your existing visitor authentication in front of it.
3. Move `RUNBEAR_ASSISTANT_ID` next to `RUNBEAR_API_KEY` in your server environment. Neither belongs
   in browser code.
4. Switch the client to `auth: { mode: "session", … }`. Passing a top-level `apiKey` alongside
   session mode throws at construction — that combination would ship an org key to the browser.
5. Resuming a saved conversation: send the stored thread id as `threadId` from the browser; your
   endpoint forwards it as `thread_id` on the mint call. The thread must belong to your organization
   and to the same agent, or the mint call returns 404.
6. Delete the catch-all proxy route once traffic has moved.

Migration is reversible at any time: proxy mode is fully supported, so switching the client options
back is the rollback.

**What you give up in v1** — if any of these is load-bearing for you, stay on proxy mode:

* **File attachments.** Uploads are not available to a session pass in v1.
* **Suggested follow-up questions.** `config.suggestions.enabled` is a no-op in session mode.
* **Server-side inspection of every message.** By design — your server is no longer in the path.

## What a session pass can do [#what-a-session-pass-can-do]

A pass is accepted on exactly three endpoints, and only for the thread and agent it was minted for:

| Endpoint                                  | Purpose                               |
| ----------------------------------------- | ------------------------------------- |
| `GET /v1/threads/{threadId}/messages`     | Read this conversation's history      |
| `POST /v1/threads/{threadId}/runs`        | Send a message and wait for the reply |
| `POST /v1/threads/{threadId}/runs/stream` | Send a message and stream the reply   |

Everything else returns **403**, including: creating or listing threads, listing or reading agents
and their instructions, traces and analytics, knowledge-base and tool configuration, API-key
management, file upload, suggested follow-ups, and minting or refreshing another session. A request
naming a **different agent** is also a `403`. A request naming a **different thread*&#x2A; — one of the
three routes above, but with a thread id the pass was not minted for — is a &#x2A;*`404`**, worded and
shaped exactly like a thread that does not exist: Runbear deliberately does not confirm whether
someone else's thread id is real.

**Not supported in session mode in v1**

* **File upload / attachments.** Uploaded files are stored as publicly readable objects, so granting
  upload to a browser credential would turn every visitor attachment into a permanent public URL.
  Use proxy mode if attachments matter to you.
* **Suggested follow-up questions.** The suggestions endpoint is not bound to a conversation, so it
  cannot be authorized by a session pass. Setting `config.suggestions.enabled` in session mode is a
  no-op and the SDK warns once in the console.
* **Reading agent metadata from the API.** The widget's displayed name and avatar come from SDK
  configuration (`config.assistant.name`, `config.assistant.avatarUrl`), not from an API read.
* **Listing a visitor's past conversations.** A pass sees one thread. Keep your own list of thread
  ids per user if you need a history picker, and mint against the chosen one.

## Errors and renewal [#errors-and-renewal]

The SDK handles renewal for you; this is what it is doing.

| Status                              | Meaning                                                                                                                                                                                                           | What happens                                                                                                                                                                                                                                                                                                                                                                                                            |
| ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `401` with `code: "pass_expired"`   | The pass aged out.                                                                                                                                                                                                | The SDK calls your `fetchSessionToken` again with the stored resume token and replays the request once.                                                                                                                                                                                                                                                                                                                 |
| `401` with **no** `code`            | An older API build that predates the `code` vocabulary.                                                                                                                                                           | Treated exactly like `pass_expired` — one bounded renewal and one replay, never a loop.                                                                                                                                                                                                                                                                                                                                 |
| `401` with any other `code`         | The credential is not usable.                                                                                                                                                                                     | Terminal. The SDK clears stored credentials and emits `sessionFailed`.                                                                                                                                                                                                                                                                                                                                                  |
| `403`                               | The pass is not allowed to do this.                                                                                                                                                                               | Terminal. Renewing cannot change an authorization decision.                                                                                                                                                                                                                                                                                                                                                             |
| `404` on a session-authorized route | The bound thread is gone, or the request named a different one.                                                                                                                                                   | Terminal for that session; the SDK emits `sessionFailed` and does **not** silently start a new conversation.                                                                                                                                                                                                                                                                                                            |
| `404` from `POST /v1/sessions`      | The `assistant_id` does not name an agent your organization owns — or a `thread_id` you passed does not belong to that agent. Runbear answers the same way for both, and for an agent that simply does not exist. | Check the agent id first; it is the usual cause.                                                                                                                                                                                                                                                                                                                                                                        |
| `429`                               | Rate-limited.                                                                                                                                                                                                     | Runbear rate-limits per organization and per session. Responses on these routes carry `RateLimit-*` headers and a `429` carries `Retry-After`. Most `429`s are transient and the SDK surfaces them as an ordinary chat error; a session that has exhausted its lifetime allowance answers `429` with `code: "session_turn_cap_exhausted"` or `"session_refresh_cap_exhausted"`, which is terminal — mint a new session. |

Your endpoint returning a non-2xx is also terminal for that session — that is the kill switch working
as designed.

A conversation that is created by a mint call and never receives a message is removed after 7 days.
A stored thread id for such an empty conversation stops resolving; mint a fresh session in that case.

## Where to go next [#where-to-go-next]

* [Runbear SDK Docs](https://app.notion.com/p/runbearinc/Runbear-SDK-Docs-3a730451bece800f8f9dd6393ae9e3b7) — every option, event, and type.
* [Runbear API Docs](https://app.notion.com/p/runbearinc/Runbear-API-Docs-1b330451bece80e09eb4c665814f06ed) — the full REST surface, including the Sessions endpoints.
* [OpenAPI reference](https://api.runbear.io/v1/docs) — exact request and response schemas for
  `POST /v1/sessions`, `POST /v1/sessions/refresh`, and the three session-authorized endpoints.
* [Traces API](./traces) — read the agent's execution traces from your own backend.
* [MCP Server](./mcp-server) — manage agents from an AI client instead of the dashboard.
