# DocuBot integration reference

For AI agents and developers adding DocuBot to a product. Everything needed is
on this page.

DocuBot answers questions about a product from its documentation wiki. Users
ask in your product; DocuBot answers from that wiki, streaming the answer as
it's written.

## Choose an integration

| You're building | Use |
|---|---|
| A help chat in a React 19 app (e.g. Next.js 15+) | **The widget** ([Part A](#a-react-widget)): a chat launcher and panel, plus a one-line server route. No API code. |
| Your own chat UI in a React 19 app | **The widget's hook**, `useDocubotChat` ([A6](#a6-your-own-ui)), with the same server route. |
| Answers in a backend, a workflow, a bot, or a non-React frontend | **The Chat API** ([Part B](#b-chat-api)), called from your server. |

Prefer the widget whenever it fits: it already handles streaming, keeping the
conversation, research polling and errors.

## Common to both

- Base URL: `https://www.docubot.wiki`
- Credential: a **project API token**, `dbk_` followed by 40 letters and
  digits. A project manager creates it in the DocuBot dashboard: **project →
  API access → New token**. It's shown once.
- A token belongs to exactly **one project**, meaning one wiki. It can't reach
  any other.
- **The token stays on the server**, e.g. in the env var `DOCUBOT_API_TOKEN`.
  Never put it in a browser, mobile app or public repository. The widget's
  server route holds it; the Chat API is called from your backend.
- Revoking a token takes effect immediately. To rotate, create the new token,
  deploy it, then revoke the old one.

## A. React widget

Package: [`@synergy-shock/docubot-widget`](https://www.npmjs.com/package/@synergy-shock/docubot-widget)
(MIT). Two halves:

- `<DocuBotWidget />`: the chat launcher and panel, in the browser.
- `@synergy-shock/docubot-widget/server`: a route handler on your server that
  holds the token and relays messages to DocuBot. The browser only ever talks
  to this route.

```
browser ── <DocuBotWidget /> ──► your /api/docubot ──► DocuBot
                                  (holds the token)
```

### A1. Requirements

- React 19 and react-dom 19 (peer dependencies). Next.js 15 and 16 ship them.
- A server for the route: the Next.js App Router, or anything that runs
  standard `Request` → `Response` handlers (A5).

### A2. Setup in a Next.js app

1. Install:
   ```bash
   npm install @synergy-shock/docubot-widget
   ```
2. Set `DOCUBOT_API_TOKEN`. On Vercel: Project → Settings → Environment
   Variables. Locally: `.env.local`.
3. Add the route. It handles `POST` (messages) and `GET` (research polling) at
   the same path:
   ```ts
   // app/api/docubot/route.ts
   export { POST, GET } from "@synergy-shock/docubot-widget/server"
   ```
4. Render the widget once, in the root layout, inside `<body>` after
   `{children}`:
   ```tsx
   // app/layout.tsx
   import { DocuBotWidget } from "@synergy-shock/docubot-widget"

   export default function RootLayout({ children }: { children: React.ReactNode }) {
     return (
       <html lang="en">
         <body>
           {children}
           <DocuBotWidget title="Acme help" appearance={{ primaryColor: "#4f46e5" }} />
         </body>
       </html>
     )
   }
   ```

No CSS to import, no props required, and no `"use client"`: the layout can stay
a Server Component. The widget renders only in the browser, inside its own
Shadow DOM, so the page's CSS can't break it.

Visitors get a launcher in a corner that they can drag anywhere, and a panel
with answers streamed as they're written, linking to the docs. The
conversation survives page loads. On phones the panel is full screen. Enter
sends and Escape closes.

### A3. Props

All optional.

| Prop | Default | Notes |
|---|---|---|
| `endpoint` | `"/api/docubot"` | Where the route is mounted. |
| `title` | `"Ask AI"` | Panel heading. |
| `subtitle` | `"Answers from our documentation"` | `""` hides it. |
| `avatarUrl` | a sparkle | Square logo in the header. |
| `showAvatar` | `true` | Show the header logo at all. |
| `headerSize` | `"small"` | `"small"`, `"medium"` or `"large"`. |
| `messageStyle` | `"bubbles"` | Answers in `"bubbles"` or as `"plain"` text. |
| `icons` | | `{ launcher, close, avatar, send, newConversation }`, each an `<svg>`, an `<img>` or an emoji. |
| `welcomeMessage` | a greeting | First message. Markdown links and bold work. |
| `placeholder` | `"Ask a question…"` | |
| `suggestions` | `[]` | Starter questions, shown until the first message. |
| `launcherLabel` | icon only | Text next to the launcher icon. |
| `position` | `"bottom-right"` | Starting corner: `"bottom-right"`, `"bottom-left"`, `"top-right"`, `"top-left"`. |
| `draggable` | `true` | Visitors can move the widget. |
| `theme` | `"light"` | `"dark"`, or `"auto"` to follow the visitor's system. |
| `persist` | `"session"` | Where the conversation survives a reload: `"session"` (this tab), `"local"` (this browser), `"none"`. |
| `appearance` | | Colors, font and sizes (A4). |
| `labels` | English | The widget's own strings, to translate: `open`, `close`, `newConversation`, `send`, `thinking`, `researching`, `researchFailed`, `poweredBy`. |
| `branding` | `true` | The "Powered by DocuBot" line. |
| `defaultOpen` | `false` | |
| `isolate` | `true` | Shadow DOM isolation. Set `false` only to style with `classNames`. |
| `classNames` | | Extra classes per part (`launcher`, `panel`, `header`, `messages`, `userMessage`, `assistantMessage`, `input`, `sendButton`, …). Only with `isolate={false}`; mark them important to beat the widget's own styles. |

### A4. Appearance

`appearance` keys (colors as CSS strings, sizes as numbers in px):
`primaryColor`, `primaryTextColor`, `backgroundColor`, `surfaceColor`,
`textColor`, `mutedTextColor`, `borderColor`, `linkColor`, `fontFamily`
(default: the page's font), `fontSize`, `radius`, `width`, `height`, `offset`,
`offsetX`, `offsetY`, `zIndex`, `userBubbleColor`, `userBubbleTextColor`,
`assistantBubbleColor`, `assistantBubbleTextColor`, `bubbleRadius`,
`headerBackgroundColor`, `headerTextColor`, `launcherColor`,
`launcherIconColor`, `launcherSize`.

From CSS instead, set the matching variables on the `docubot-widget` element
(not `:root`), e.g. `docubot-widget { --docubot-primary: #4f46e5; }`. Named
parts can be styled with `docubot-widget::part(panel)`; parts: `launcher`,
`panel`, `header`, `avatar`, `title`, `messages`, `user-message`,
`assistant-message`, `suggestion`, `composer`, `input`, `send`, `footer`.

### A5. Server options

The ready-made `POST` and `GET` read `DOCUBOT_API_TOKEN`, and
`DOCUBOT_BASE_URL` if it's set. To configure them in code, use
`createDocubotHandler`:

```ts
// app/api/docubot/route.ts
import { createDocubotHandler } from "@synergy-shock/docubot-widget/server"
import { auth } from "@/auth"

export const { POST, GET } = createDocubotHandler({
  // Who's asking: shown next to the conversation in the DocuBot dashboard.
  actor: async () => (await auth())?.user?.email ?? null,
})
```

| Option | Default | Notes |
|---|---|---|
| `token` | `process.env.DOCUBOT_API_TOKEN` | |
| `baseUrl` | `process.env.DOCUBOT_BASE_URL`, else `https://www.docubot.wiki` | |
| `actor` | none | `(req: Request) => string \| null`, may be async. |
| `messages` | English | What visitors read when something goes wrong: `{ unavailable, tooMany, busy, invalid }`. |
| `fetch` | the global `fetch` | For tests or proxies. |

`POST` and `GET` are standard `(req: Request) => Promise<Response>` handlers,
so the route also works outside Next.js, anywhere Fetch-API handlers do (React
Router, Hono, SvelteKit, Astro…). Mount both at one path and pass that path as
the widget's `endpoint`.

### A6. Your own UI

`useDocubotChat` gives the conversation without the widget's UI, through the
same route. Use it in a Client Component:

```tsx
"use client"
import { useState } from "react"
import { Markdown, useDocubotChat } from "@synergy-shock/docubot-widget"

export function HelpChat() {
  const { messages, busy, research, send, reset } = useDocubotChat({ endpoint: "/api/docubot" })
  const [text, setText] = useState("")
  return (
    <div>
      {messages.map((m) => (
        <div key={m.id} data-role={m.role}>
          {m.role === "assistant" ? <Markdown text={m.text || "…"} /> : m.text}
        </div>
      ))}
      {research && <p>Looking into it… {research.phase}</p>}
      <form
        onSubmit={(e) => {
          e.preventDefault()
          send(text)
          setText("")
        }}
      >
        <input value={text} onChange={(e) => setText(e.target.value)} disabled={busy} />
      </form>
      <button onClick={reset}>New conversation</button>
    </div>
  )
}
```

- Options: `endpoint` (default `"/api/docubot"`), `persist` (`"session"`,
  `"local"` or `"none"`), `researchFailedMessage`.
- `messages`: `{ id, role: "user" | "assistant", text, error? }[]`. An
  assistant message with empty text is still being written.
- `busy`: a reply is streaming. `send(text)` does nothing while it's true.
- `research`: `{ runId, phase } | null` while background research runs. Its
  answer arrives as a new assistant message.
- `reset()`: starts a new conversation and stops a reply in flight.
- `<Markdown text />` renders an answer like the widget does: links, bold,
  lists and code, never HTML.

### A7. Errors and security

- A missing, revoked or wrong token, or an outage: visitors read "The
  assistant isn't available right now", and the cause is logged on your
  server as `[docubot] …`.
- Over a token limit (HTTP 429 from DocuBot): visitors read "Too many questions
  right now…" for a per-minute limit, or "The assistant is busy right now…"
  for a daily one.
- The route is public, which is what lets visitors chat, and every message is
  an AI call. Rate-limit it per visitor, e.g. with a Vercel Firewall rule on
  `/api/docubot`.

## B. Chat API

Call it from your server. Three endpoints: open a conversation, send a message
(streamed reply), poll background research. Every call except research
polling takes `Authorization: Bearer <token>`. DocuBot keeps the conversation
history, so send only the new message each time.

### Endpoints

| Method | Path | Auth | Purpose |
|---|---|---|---|
| `POST` | `/api/chat-sessions` | token | Open a conversation. Returns `{ "chatId" }`. |
| `POST` | `/api/chat/{chatId}` | token | Send one user message. Returns a server-sent event stream. |
| `GET` | `/api/wiki-runs/{runId}/status-summary` | none | Poll a background research run. |

### B1. Open a conversation

```http
POST /api/chat-sessions?actor=ana%40acme.com
Authorization: Bearer dbk_…
```

- **Body:** none. The token identifies the project. `{ "projectId": "…" }` is
  accepted but must be the token's own project.
- **`actor`** (optional query parameter): any string identifying the human
  behind the conversation, e.g. an email or user id. It's shown next to the
  conversation in the DocuBot dashboard.

**200**

```json
{ "chatId": "qg8uXnyJPU" }
```

`chatId` is an opaque string. Open **one conversation per end-user
conversation** (a support ticket, a chat thread) and reuse its `chatId` for
every message in it: follow-up questions and the research flow (B3) depend on
the history. Don't share a `chatId` across users or unrelated topics.

### B2. Send a message

```http
POST /api/chat/{chatId}
Authorization: Bearer dbk_…
Content-Type: application/json
```

```json
{
  "id": "qg8uXnyJPU",
  "message": {
    "id": "5b0b4b7e-8f5e-4d55-9a83-3f8e1c2d7a10",
    "role": "user",
    "parts": [{ "type": "text", "text": "How do I add a second location?" }]
  }
}
```

| Field | Required | Notes |
|---|---|---|
| `id` | no | The `chatId`. |
| `message.id` | yes | A **new** unique id (a UUID) for every message you send, retries included. |
| `message.role` | yes | Always `"user"`. |
| `message.parts` | yes | One `{ "type": "text", "text": "…" }` part with the user's message. |

**200**: `Content-Type: text/event-stream`. The answer streams while it's
being written. A reply can take a minute or more, and the server allows up to
300 seconds, so don't time out sooner. Each event is one line,
`data: <json>`, followed by a blank line. The stream ends with
`data: [DONE]`.

| Event `type` | Fields | What to do |
|---|---|---|
| `text-delta` | `id`, `delta` | Append `delta` to the answer. `id` names the text part: when it changes (the model wrote again after a tool call), start a new paragraph (`"\n\n"`). |
| `data-extend-run` | `data.runId` | Background research started (B3). Keep `data.runId`. |
| `error` | `errorText` | The reply failed. `errorText` is a plain sentence, safe to show the user. It arrives inside a `200` response. |
| anything else | | Ignore: `start`, `start-step`, `text-start`, `text-end`, tool events, `finish-step`, `finish`. New types may be added. |

The answer is Markdown in the user's language: paragraphs, lists, bold, and
links to the relevant wiki pages. Show it as-is, or render the Markdown.

### B3. Background research (optional)

When the wiki doesn't cover a question, DocuBot says so and offers to look
into it. If the user accepts in their next message, DocuBot starts a research
run: it reads the product's source, writes the missing documentation and
produces an answer. That takes minutes, so the answer arrives asynchronously.

1. The reply to the accepting message contains a `data-extend-run` event. Its
   text is an acknowledgement ("I'll look into it…"); show it.
2. Poll every ~5 seconds, without auth:

```http
GET /api/wiki-runs/{runId}/status-summary
```

**200**

```json
{
  "status": "running",
  "phaseLabel": "Reading how locations are configured",
  "startedAt": "2026-09-29T12:00:00.000Z",
  "finishedAt": null,
  "finalAnswer": null,
  "newFiles": null,
  "modifiedFiles": null,
  "publicUrlBase": "https://acme.docubot.wiki/app/",
  "error": null
}
```

3. **Deliver `finalAnswer` as soon as it's not null**, whatever `status` says.
   The answer is written before the documentation is published, so a run can
   end `failed` and still carry a good answer. It's Markdown, like a reply.
4. Stop polling when `finalAnswer` arrives, when `status` is terminal
   (`succeeded`, `no_changes`, `failed`, `needs_review`), or after ~15 minutes.
   With no `finalAnswer`, tell the user the lookup didn't find an answer.

- `phaseLabel`: a short, user-safe description of the current step. Fine to
  show as progress.
- `error`: an internal diagnostic. Log it if you like; never show it to users.

Not supporting research is fine: ignore `data-extend-run`, and users still get
DocuBot's "that isn't documented" reply.

### Errors

| Status | Endpoint | Body | Meaning and what to do |
|---|---|---|---|
| `400` | both `POST`s | JSON `{ "error", "detail"? }` | Malformed request. Fix it; don't retry. |
| `401` | both `POST`s | `{ "error": "Unauthorized" }` or the text `Unauthorized` | Missing, unknown or **revoked** token. Don't retry. |
| `404` | `/api/chat-sessions` | `{ "error": "Project not found" }` | `projectId` isn't the token's project. |
| `404` | `/api/chat/{chatId}` | the text `Chat session not found` | The conversation is unknown, deleted, or another project's. Open a new conversation and resend **once**. |
| `404` | `/api/wiki-runs/…` | `{ "error": "Run not found" }` | Unknown `runId`. |
| `429` | both `POST`s | `{ "error", "detail", "retryAfter" }` | Over a usage limit. Wait `Retry-After` seconds (also in the body). |
| `5xx` | any | varies | DocuBot is unavailable. Retry later, with backoff. |

### Limits

Tokens are currently unlimited, but per-token limits may be enabled later, on
new conversations, messages and research runs. Handle `429` now:

```json
{ "error": "Rate limit exceeded", "detail": "This API token allows 500 messages per day.", "retryAfter": 3600 }
```

A research limit never fails a request. When it's reached, DocuBot stops
offering to look things up until the next UTC day.

### Implementation checklist

1. Keep the token on the server. Read it from an env var.
2. Store one `chatId` per end-user conversation. Open it lazily on the first
   message.
3. Send a fresh `message.id` (UUID) with every message.
4. Read the stream line by line. Lines can be split across network chunks:
   buffer until a newline.
5. Build the answer from `text-delta`, with a paragraph break when `id`
   changes.
6. On `error` events, show `errorText`.
7. On `404` from `/api/chat/{chatId}`, open a new conversation and resend
   once.
8. Respect `429` and `Retry-After`.
9. Optional: on `data-extend-run`, poll the run and deliver `finalAnswer`.

### Complete example

TypeScript, no dependencies. Runs in Node 20+ and Bun; elsewhere, only how
`TOKEN` is read changes.

```ts
const BASE = "https://www.docubot.wiki"
const TOKEN = process.env.DOCUBOT_API_TOKEN!
const auth = { authorization: `Bearer ${TOKEN}` }

/** Open a conversation. Keep the chatId for the whole end-user conversation. */
export async function openConversation(actor?: string): Promise<string> {
  const qs = actor ? `?actor=${encodeURIComponent(actor)}` : ""
  const res = await fetch(`${BASE}/api/chat-sessions${qs}`, { method: "POST", headers: auth })
  if (!res.ok) throw new Error(`docubot ${res.status}: ${await res.text()}`)
  return ((await res.json()) as { chatId: string }).chatId
}

/** Ask one question. Returns the answer, the chatId used, and a research run id if one started. */
export async function ask(chatId: string | null, text: string) {
  chatId ??= await openConversation()
  const send = (id: string) =>
    fetch(`${BASE}/api/chat/${id}`, {
      method: "POST",
      headers: { ...auth, "content-type": "application/json" },
      body: JSON.stringify({
        id,
        message: { id: crypto.randomUUID(), role: "user", parts: [{ type: "text", text }] },
      }),
    })

  let res = await send(chatId)
  if (res.status === 404) {
    chatId = await openConversation() // expired or deleted: start over, once
    res = await send(chatId)
  }
  if (res.status === 429) throw new Error(`docubot busy, retry in ${res.headers.get("retry-after")}s`)
  if (!res.ok || !res.body) throw new Error(`docubot ${res.status}: ${await res.text()}`)

  let answer = ""
  let part: string | undefined
  let runId: string | null = null
  let buffer = ""
  const reader = res.body.getReader()
  const decoder = new TextDecoder()
  for (;;) {
    const { done, value } = await reader.read()
    if (done) break
    buffer += decoder.decode(value, { stream: true })
    const lines = buffer.split("\n")
    buffer = lines.pop() ?? ""
    for (const line of lines) {
      if (!line.startsWith("data:")) continue
      const payload = line.slice(5).trim()
      if (!payload || payload === "[DONE]") continue
      const event = JSON.parse(payload)
      if (event.type === "text-delta") {
        if (part !== undefined && event.id !== part) answer += "\n\n"
        part = event.id
        answer += event.delta
      } else if (event.type === "data-extend-run") {
        runId = event.data.runId
      } else if (event.type === "error") {
        answer += (answer ? "\n\n" : "") + event.errorText
      }
    }
  }
  return { chatId, answer, runId }
}

/** Wait for a research run's answer. Null when it ends without one. */
export async function research(runId: string): Promise<string | null> {
  const terminal = new Set(["succeeded", "no_changes", "failed", "needs_review"])
  for (let i = 0; i < 180; i++) {
    await new Promise((r) => setTimeout(r, 5_000))
    const res = await fetch(`${BASE}/api/wiki-runs/${runId}/status-summary`)
    if (!res.ok) continue
    const run = (await res.json()) as { status: string; finalAnswer: string | null }
    if (run.finalAnswer) return run.finalAnswer
    if (terminal.has(run.status)) return null
  }
  return null
}

// const { chatId, answer, runId } = await ask(null, "How do I add a second location?")
// console.log(answer)
// if (runId) console.log(await research(runId))
```
