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.
| 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
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 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. 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.
endUseris 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
- Upgrade to
@runbear-io/react0.2.0 or newer. - Add the session route; keep your existing visitor authentication in front of it.
- Move
RUNBEAR_ASSISTANT_IDnext toRUNBEAR_API_KEYin your server environment. Neither belongs in browser code. - Switch the client to
auth: { mode: "session", … }. Passing a top-levelapiKeyalongside session mode throws at construction — that combination would ship an org key to the browser. - Resuming a saved conversation: send the stored thread id as
threadIdfrom the browser; your endpoint forwards it asthread_idon the mint call. The thread must belong to your organization and to the same agent, or the mint call returns 404. - 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.enabledis 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:
| 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 — 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.enabledin 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.
| 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 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.