RunbearRunbear Docs
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.

This page covers how the widget authenticates. For the exhaustive option, event and type tables, see the Runbear SDK Docs.

Private package

The SDK is published to GitHub Packages, so installing it needs a GitHub token with read:packages. Session mode requires @runbear-io/react 0.2.0 or newer.

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:

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

Quick start

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

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.

ModeWhere the credential livesWho relays the chatUse it when
sessionA short-lived, thread-bound session pass your server mintsNobody — the browser talks to api.runbear.io directlyYou want your server out of the per-message path
proxyYour server; the browser sends no Authorization headerYour server relays every messageYou must inspect, log or redact every turn, or you need file attachments or suggested follow-ups
directThe org API key, in the browserNobodyLocal prototyping and fully internal tools only

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

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.

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

Direct mode (development only)

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

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

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.

Your session endpoint

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

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.

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)
}

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.

Runbear responseSDK SessionCredentialsNotes
pass.tokenpassRequired. The browser credential.
session.thread_idthreadIdRequired. The one thread this pass may read and run.
session.idsessionIdRequired. Log it — it is the join key across the session's requests.
pass.expiresInSecondsexpiresInRequired. Seconds, measured by the server.
resumeToken.tokenresumeTokenOptional. Present on mint, absent on every refresh.
pass.expiresAt, resumeToken.expiresAt, resumeToken.expiresInSeconds, session.assistant_id, session.expiresAtnot forwardedServer-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.

CredentialCurrent default lifetimeWhat it can doWhere it lives
pass15 minutesRead and run exactly one thread with exactly one agentThe browser, in the Authorization header
resumeToken24 hours when you send endUser; 2 hours without itObtain a fresh pass. 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.

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.

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

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

Before — your server forwards every message.

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

Before — browser.

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

After — browser.

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

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

EndpointPurpose
GET /v1/threads/{threadId}/messagesRead this conversation's history
POST /v1/threads/{threadId}/runsSend a message and wait for the reply
POST /v1/threads/{threadId}/runs/streamSend 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 — one of the three routes above, but with a thread id the pass was not minted for — is a 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

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

StatusMeaningWhat 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 codeAn 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 codeThe credential is not usable.Terminal. The SDK clears stored credentials and emits sessionFailed.
403The pass is not allowed to do this.Terminal. Renewing cannot change an authorization decision.
404 on a session-authorized routeThe 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/sessionsThe 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.
429Rate-limited.Runbear rate-limits per organization and per session. Responses on these routes carry RateLimit-* headers and a 429 carries Retry-After. Most 429s 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

  • Runbear SDK Docs — every option, event, and type.
  • Runbear API Docs — the full REST surface, including the Sessions endpoints.
  • OpenAPI reference — exact request and response schemas for POST /v1/sessions, POST /v1/sessions/refresh, and the three session-authorized endpoints.
  • Traces API — read the agent's execution traces from your own backend.
  • MCP Server — manage agents from an AI client instead of the dashboard.