# sideagent > Embed a docs chat agent in your app. Give it your documentation and an OpenAI-compatible LLM host and key; get a secure agent endpoint (`sideagent/server`) and a React chat UI (`sideagent`). # Introduction Source: https://egig.github.io/sideagent/ Embed a docs chat agent in your app. You give it your documentation (markdown and URLs) and an OpenAI-compatible host and key. It gives you a secure agent endpoint and a React chat UI that answers from your docs and shows its sources. ``` npm i sideagent ``` - [Server](./server.md): `createAgent` and framework handlers. - [Client](./client.md): the `AgentChat` component and docked sidebar. - [Knowledge](./knowledge.md): the documentation the agent answers from. - [Theming](./theming.md), [History](./history.md). - [Example](./example.md): a runnable app with a mock LLM, no API key needed. --- # Quickstart Source: https://egig.github.io/sideagent/quickstart Let your coding agent (Claude Code, Cursor, Codex and others) add sideagent to your app. Install the skill once: ``` npx skills add egig/sideagent ``` Then prompt your agent: > Add an AI assistant to this app. The skill is also in the npm package at `node_modules/sideagent/skills/sideagent/SKILL.md`. If your agent has no skills support, ask it to read `https://egig.github.io/sideagent/llms-full.txt` and follow it. ## What the agent does 1. Finds your server framework, your React frontend and your documentation. 2. Installs `sideagent`. 3. Adds the `/agent` route with `createAgent`, with your docs as `knowledge` and your existing auth in `getAuth`. 4. Mounts `AgentProvider` or `AgentChat` in your app shell. 5. Adds the env vars to `.env.example`. 6. Runs your typecheck and tests, checks `agent.doctor()`, and asks you to confirm that no source is private. ## Defaults the agent follows - The agent answers from knowledge only. It cannot call your API or change data. If you ask for that, the skill says so and builds the docs agent. - `getAuth` returns a stable per-user `userId`. - Keys come from env vars and never reach the browser. You must still review the result, especially the knowledge sources and `getAuth`. See the [Deployments](./deployments.md) checklist before going to production. ## How the skill is tested The skill has eval cases in the repo (`skills/sideagent/evals/`). Each one gives a coding agent a small fixture app and a prompt, then grades the result with and without the skill: - `integrate-hono`: "Implement agentic AI for this SaaS." The agent must add the `/agent` route, feed it the app's docs, reuse the app's auth and read the key from an env var. - `no-actions`: the user asks for create and delete access and no approvals. The agent must not wire the agent to the API, and must say that sideagent only answers from documentation. Run them with `pnpm eval:skill`. ## Set it up by hand Follow [Server](./server.md) for the `/agent` route and [Client](./client.md) for the chat UI. ## Other agent files - [`llms.txt`](pathname:///sideagent/llms.txt): index of these docs for LLMs. - [`llms-full.txt`](pathname:///sideagent/llms-full.txt): all docs in one file. - `AGENTS.md` in the repo: commands and rules for agents that work on the sideagent code itself. --- # Server Source: https://egig.github.io/sideagent/server Import from `sideagent/server`. ```ts import { createAgent, honoHandler } from "sideagent/server"; const agent = await createAgent({ llm: { baseUrl: "https://api.openai.com/v1", apiKey: process.env.LLM_API_KEY!, model: "gpt-4o-mini" }, // Or pass `model: yourLangChainChatModel` instead of `llm` (see Cloudflare Workers). knowledge: { embeddings: { model: "text-embedding-3-small" }, sources: [{ id: "guide", title: "Invoicing guide", content: markdownString }], urls: ["https://docs.example.com/payment-terms"], }, scope: {}, // keeps the agent on the docs allowedOrigins: ["https://app.example.com"], getAuth: (req) => ({ userId: "…" }), }); app.on(["POST", "OPTIONS"], "/agent", honoHandler(agent)); // also: nextRoute, expressHandler console.log(await agent.doctor()); // LLM check + what was indexed ``` The agent has one tool, `search_knowledge`. It answers from the passages it finds and cannot call your API or change data. See [Knowledge](./knowledge.md) for sources, `scope` and the vector store. `llm.fetch` and `knowledge.embeddings.fetch` take a `fetch` function that carries every HTTP call to the model and embeddings endpoint (embeddings fall back to `llm.fetch`). Use it to route those calls through your own guard, for example one that blocks private hosts when the `baseUrl` comes from a customer. They do not apply to `model` or `embeddings` you pass in yourself. `llm.maxRetries` and `knowledge.embeddings.maxRetries` (0 to 6) set how often a failed call is retried; the clients retry several times with backoff by default, which makes a blocked or dead endpoint slow to fail. ## Long threads: `summarize` `summarize` replaces the older messages of a long thread with a summary the model writes, so long conversations stay within the model's context. It is off by default, and each summary is one extra model call. ```ts createAgent({ …, summarize: { afterTokens: 8000, keepMessages: 20 } }); ``` - A summary is made before a model call once the thread passes `afterTokens` (an approximate count). The last `keepMessages` messages stay as they are. `keepMessages` must be at least `limits.maxSteps * 2 + 1`, so the current turn is never summarized. - The summary can repeat knowledge passages, so the model gets it inside a `` block, never as the user's words. The system prompt holds that block to the same rule as passages: context only, never instructions. - Passage text that goes into a summary still never reaches the browser unless `exposeKnowledge` is on. Source links of a summarized search are gone with it. - If the summary call fails, the thread is left as it was. - Summarized messages are gone from the thread: the History panel shows only the messages that were kept, and does not show the summary. ## Behavior and limits - The model has one tool, `search_knowledge`. A call to any other tool name is answered with an error and never runs. - Requests from origins not in `allowedOrigins` are rejected. - `getAuth` runs on every request. Threads are bound to the `userId` it returns. - Call `agent.doctor()` at startup to see whether the LLM is reachable and how many chunks each knowledge source produced. --- # Client Source: https://egig.github.io/sideagent/client ```tsx import { AgentChat } from "sideagent"; track("agent_turn") }} /> ``` ## sideagent Cloud With an agent hosted on sideagent Cloud, pass its publishable key instead of `endpoint`. The key names the agent and its host, so the chat derives the URL (`https:///v1/agents/`) and sends the key in the `x-sideagent-key` header on every request. The key is public: it is safe in the browser and proves nothing on its own. ```tsx ``` Pass `endpoint` as well to send the requests somewhere else, such as a proxy in front of sideagent Cloud. A malformed key, or neither prop, throws during render. `parsePublishableKey` and `publishableEndpoint` are exported from `sideagent/keys`. The panel title is the agent's name from the Cloud dashboard. The chat asks for it once with `{ "action": "agent.info" }` and shows "Assistant" until it answers, or if the request fails. Pass `title` to use your own text. Without a `publishableKey` the title is "Assistant" and nothing is asked. The same answer can carry `"available": false`, when the agent would refuse every message because a usage cap is reached (sideagent Cloud does this for anonymous visitors). The chat then renders nothing, launcher included, until the next page load. Under `AgentProvider`, `useAgent().available` is `false` too, so hide your own "ask" button. Signed-in users: pass `getHeaders`, which the chat calls before every request, so a login token that expires is fetched fresh each time. Pass `storageKey` (the user's ID) so the last conversation the chat remembers in `localStorage` belongs to that user; on a shared browser another user starts from their own. ```tsx ({ Authorization: `Bearer ${await getToken()}` })} storageKey={user.id} /> ``` `getHeaders` works with `endpoint` too. Its headers are merged under `headers`, which wins on a clash, so it cannot replace the publishable key. If it throws, the request is not sent and the chat shows an error. Hide the floating launcher with `launcher={false}` and open the panel from your own button through `useAgent().setOpen`. Turn off chat history with `history={false}`: no History button, and the chat does not remember or reopen the last conversation. Hide the New chat button with `newChatButton={false}`. Add your own buttons to the panel header with `headerActions`. ## Docked sidebar Wrap your app in `AgentProvider` to dock the chat on the right, side by side with the app instead of over it. It takes the same props as `AgentChat`. The user drags the panel's left edge (or uses the arrow keys on it) to resize; the width is remembered. Outside `AgentProvider`, sidebar mode overlays the page. ```tsx import { AgentProvider } from "sideagent"; ``` `AgentProvider` fills the viewport height (`100dvh`); override with the `className` or `style` props. Anything under `AgentProvider` can call `useAgent()` to get `{ open, setOpen, available }`, e.g. an "Ask assistant" button in your toolbar. ## Small screens At 640px wide or less, the panel opens full screen in every mode (floating, sidebar and docked) and the layout switcher and resize handle are hidden. It respects the safe-area insets when your page sets `viewport-fit=cover`. On touch devices the buttons are larger and the keyboard hint is hidden. The page needs a responsive viewport tag: ``. ## Limits - Without `AgentProvider`, `mode="sidebar"` overlays the page instead of docking. - The sidebar width is remembered in the browser. It is not shared across devices. --- # Hooks Source: https://egig.github.io/sideagent/hooks Hooks are your code that runs at set points. There are server hooks (on `createAgent`) and client hooks (on `AgentChat` / `AgentProvider`). ## Server hooks ### `getAuth(request)` Runs on every request, including history calls. Return `{ userId }`. Throw, or return no `userId`, and the request gets `401`. See [Deployments](./deployments.md#authentication). ### `beforeTurn({ request, userId })` Runs before a chat turn starts. It does not run for history list, open or delete. Throw to reject the turn with `429`. The error message is sent to the client. ```ts beforeTurn: async ({ userId }) => { if (!(await limiter.allow(userId))) throw new Error("Too many requests. Try again in a minute."); }, ``` Use it for rate limits and quotas. It runs before the concurrent-turn check, so a rejected turn does not count as active. ### `callbacks({ request, userId })` Returns [LangChain callback handlers](https://js.langchain.com/docs/concepts/callbacks/) for this request's turn, to trace what happens under the hood: each model call, each tool call, token usage and errors. Return `undefined` to skip tracing for a request. ```ts callbacks: ({ userId }) => (isOwner(userId) ? [new MyTraceHandler()] : undefined), ``` - It runs on the server for every request. Nothing in the request body can turn it on. - Handlers see the raw prompts, replies, search queries and passages. They are not redacted, so redact before you store them. - A knowledge search is a span tagged `sideagent:tool` and named `search_knowledge`. Handle it in `handleChainStart` and `handleChainEnd`. - A callback that throws means the turn runs without tracing. A failing handler does not break the turn. ### `onError({ code, error, userId, threadId })` Called when a turn fails, with the real error. The browser only sees "The agent failed. Try again." (or the step limit or timeout message), so use this to log the cause. ```ts onError: ({ code, error, threadId }) => console.error("turn failed", { code, threadId, error }), ``` - `code` is `error`, `step_limit` or `timeout`, as sent to the client. - It is fire-and-forget: the turn does not wait for it, and an error thrown here is swallowed. - It does not run when the client disconnects. - `error` can contain details from your model provider or API. Log it on the server; do not send it to the browser. ## Client hooks Pass `hooks` to `AgentChat` or `AgentProvider`. The model cannot trigger a hook or any other client action. ```tsx track("agent_turn"), onError: ({ code, message }) => report(code, message), }} /> ``` | Hook | Fires | | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | | `onTurnStart()` | When the user sends a message | | `onTurnEnd()` | When the turn finishes, fails or is stopped | | `onError({ code, message })` | On an agent error. Server codes: `step_limit`, `timeout`, `error`. Client codes: `headers_failed`, `http_`, `network` | A hook that throws is caught and logged to the console as `[sideagent] hook threw:`. It never breaks the chat. Hooks are synchronous: do not rely on the chat waiting for a returned promise. --- # Theming Source: https://egig.github.io/sideagent/theming Neutral by default (grayscale, host font, 10px radius). `theme="auto"` (or `"system"`) follows the OS (unless a host `.light` / `data-theme="light"` / `data-bs-theme="light"` is set) and a host `.dark` / `data-theme="dark"` / `data-bs-theme="dark"`; pass `"light"` or `"dark"` to force one. Match your design system with CSS variables on `:root`: ```css :root { --sideagent-accent: var(--primary); /* buttons */ --sideagent-accent-fg: var(--primary-foreground); --sideagent-bg: var(--background); --sideagent-fg: var(--foreground); --sideagent-border: var(--border); --sideagent-surface: var(--muted); /* user bubble, hovers */ --sideagent-muted: var(--muted-foreground); --sideagent-radius: 10px; --sideagent-font: inherit; } ``` --- # Persistence Source: https://egig.github.io/sideagent/persistence By default everything is kept in memory and lost on restart. In production, pass your own storage to `createAgent`. All three options are independent and optional. | Option | Holds | Default | |---|---|---| | `checkpointer` | Chat messages | In-memory | | `threadStore` | History list per user (id, title, updatedAt) | In-memory | | `vectorStore` | Knowledge embeddings | In-memory | ```ts createAgent({ …, checkpointer, threadStore, vectorStore }); ``` Startup logs a warning when `checkpointer` is missing, or when it is set without `threadStore`. ## Messages: `checkpointer` Any LangGraph `BaseCheckpointSaver`. Postgres example: ```ts import { PostgresSaver } from "@langchain/langgraph-checkpoint-postgres"; const checkpointer = PostgresSaver.fromConnString(process.env.DATABASE_URL!); await checkpointer.setup(); // creates the tables; run once at startup ``` When a user deletes a thread, sideagent calls `checkpointer.deleteThread(threadId)` if the saver has it. ## History list: `threadStore` Implement `ThreadStore`. Key every row by `(userId, threadId)`. ```ts import type { ThreadStore } from "sideagent/server"; const threadStore: ThreadStore = { // newest first list: async (userId) => /* SELECT id, title, updated_at AS "updatedAt" … ORDER BY updated_at DESC */, has: async (userId, threadId) => /* SELECT 1 … WHERE user_id = $1 AND id = $2 */, // create with `title` if new; otherwise only bump updatedAt. Never overwrite the title. touch: async (userId, threadId, title) => /* INSERT … ON CONFLICT (user_id, id) DO UPDATE SET updated_at = now() */, delete: async (userId, threadId) => /* DELETE … WHERE user_id = $1 AND id = $2 */, }; ``` - On a LangGraph `BaseStore` (Postgres, Redis, …) you can skip this and use `new LangGraphThreadStore(store)` from `sideagent/server`. It keeps the same 200-thread cap as `MemoryThreadStore`. - `has` is the ownership check. A thread that is not in the user's own index returns 404, even if the id is guessed. - `updatedAt` is a number of milliseconds since epoch. - The client shows at most 50 threads. `MemoryThreadStore` keeps at most 200 per user; your store has no such cap unless you add one. ## Knowledge: `vectorStore` Any LangChain vector store: `VectorStoreInterface` from `@langchain/core/vectorstores` (pgvector, Vectorize, Qdrant, …). Sideagent embeds the chunks itself, so the store's own `embeddings` are not used. It calls: - `addVectors(vectors, documents, { ids })` with stable ids `#`. A refresh must overwrite rows with the same id (upsert). - `delete({ ids })` for chunks a refreshed source no longer has. - `similaritySearchVectorWithScore(vector, k)`. Each document carries `metadata: { sourceId, title, url? }`. ```ts import { PGVectorStore } from "@langchain/community/vectorstores/pgvector"; const vectorStore = await PGVectorStore.initialize(embeddings, { postgresConnectionOptions, tableName: "sideagent_kb" }); createAgent({ …, vectorStore }); ``` - `vector` size is set by your embedding model. The column dimension must match it. Changing `embeddings.model` needs re-embedding, so clear the table first. - With a persistent store, the agent still re-embeds sources on every start. The store only avoids keeping them in process memory. --- # Chat history Source: https://egig.github.io/sideagent/history `AgentChat` has a History button (list, open, delete) and reopens the last conversation after a refresh. Messages live in your LangGraph `checkpointer`; the list lives in a `threadStore` (in-memory by default). Both must be persistent in production. See [Persistence](./persistence.md) for setup: ```ts createAgent({ …, checkpointer: new PostgresSaver(…), threadStore: myThreadStore }); // ThreadStore: list / has / touch / delete ``` ## Limits - The default `threadStore` is in-memory. History is lost on restart, and not shared between instances. Use a persistent one in production. - A persistent `checkpointer` is required too. Without it, a thread listed in history may have no messages. - Users only see their own threads. - With [`summarize`](./server.md#long-threads-summarize) on, older messages of a long thread are replaced by a summary and no longer appear in its history. --- # Knowledge Source: https://egig.github.io/sideagent/knowledge Give the agent markdown and URL sources to answer from. ```ts createAgent({ …, knowledge: { embeddings: { model: "text-embedding-3-small" }, // OpenAI-compatible /embeddings; baseUrl/apiKey default to llm's sources: [{ id: "guide", title: "Invoicing guide", content: markdownString }], // pass strings: no filesystem on the edge urls: ["https://docs.example.com/payment-terms", { url: "https://…", title: "Shipping" }], // fetched by the server only }, vectorStore, // optional: your own VectorStore (pgvector, Vectorize…). Default is in-memory. }); ``` The model gets a `search_knowledge` tool. It is the agent's only tool. Answers show a "Sources" card, with links only for your configured URLs. ## Hybrid retrieval Search fuses two rankings with reciprocal rank fusion: the vector search, and a BM25 keyword search over the same chunks. A passage that uses the user's exact words is found even when the embedding ranks it just outside the vector cut. Turn it off with `knowledge.hybrid: false`. The in-memory store supports this out of the box. A custom `vectorStore` opts in by implementing `keywordSearch(query, k): Promise<[Document, number][]>` over the same ids `addVectors` uses; without it, search is vector-only. Keyword scores may use any scale. The `score` reported for a passage is the stronger of its cosine similarity and its keyword score, clamped to `[0, 1]`, which is what `scope.minScore` compares against. ## Semantic cache Repeated or slightly reworded questions reuse the previous result set instead of searching again. Query embeddings are compared per agent: at `minScore` cosine similarity (default 0.97), the cached hits are returned. Entries expire after `ttlMs` (default 5 minutes, bounded to `maxEntries`) and are cleared whenever a source is ingested or refreshed, so knowledge updates are visible at once. ```ts knowledge: { cache: { enabled: true, minScore: 0.97, maxEntries: 100, ttlMs: 300_000 }, } ``` The cache is per `KnowledgeBase` instance and only stores hits, never model answers or anything specific to a user. ## Showing passages to the host's own user By default the browser gets source titles only, never passage text. To let a trusted user (for example an owner previewing their agent) see what was retrieved, pass `exposeKnowledge`: ```ts createAgent({ …, exposeKnowledge: ({ request, userId }) => isOwner(userId), // decided on the server, per request }); ``` When it returns `true`, each knowledge search is followed by a `knowledge.hits` event with `{ callId, query, hits: [{ title, url?, score, text }] }`, and the Sources card gets a "Passages" section. Nothing in the request body can turn it on. A callback that throws or returns anything but `true` means no. The passages are not stored, so they do not appear when a thread is reloaded from history. Keep it off for end users: passage text can contain anything you put in your knowledge base. ## Keeping the agent on topic Prompt rules alone do not stop a model from answering "what is 2+2". Set `scope` and the agent gets fixed scope rules in its system prompt, plus a relevance gate that runs in code: ```ts createAgent({ …, scope: { minScore: 0.45, offTopic: "I can only answer questions about the documentation." }, }); ``` A turn is backed when a `search_knowledge` call in it found a passage scoring at least `minScore`, or the user's message itself matches such a passage. A turn that is not backed gets `offTopic` as its answer, and the model is not called. With `exposeKnowledge` on, the gate's search is sent like a model search: `tool.start`, `tool.complete` and `knowledge.hits`, all with `callId: "gate"`. Set `naturalReply: true` to have the model write the off-topic answer, in the user's language, instead of sending `offTopic`. The model sees only the last user message, with no tools, passages or history, and is told not to answer it. This costs one model call per off-topic turn, and the model's text reaches the user. `offTopic` is the fallback when that call fails or returns nothing. ## Limits - URLs come only from config, never from the user or the model. - URLs must be public http(s). Private ranges are blocked unless `allowPrivateNetworks` is set. Metadata addresses are always blocked. - Redirects are followed only within the same host. - Responses larger than `maxSourceBytes` are rejected. - URL sources refresh in the background after `ttlMs` (default 1h), or on `agent.knowledge.refresh()`. - A source that fails to load is skipped with a warning, and shown in `doctor()`. - The default in-memory store re-embeds every source on each start, which costs embedding calls. Use a persistent `vectorStore` for large knowledge. - Pass markdown as strings. There is no filesystem access on edge runtimes. --- # Deployments Source: https://egig.github.io/sideagent/deployments ## Checklist - [ ] `getAuth` returns a stable `userId` for each signed-in user. Threads are bound to it. Use `anonymous: true` only for public docs: every visitor then shares the user id `"anonymous"`, so they share threads too. - [ ] `allowedOrigins` lists only your real app origins. - [ ] Every `knowledge` source is something any user may read: the agent can quote any passage it finds. - [ ] `checkpointer`, `threadStore` and `vectorStore` are persistent. See [Persistence](./persistence.md). - [ ] `llm.apiKey` comes from an env var. It never goes to the browser. - [ ] `beforeTurn` enforces rate limits or quotas. - [ ] `agent.doctor()` reports `ok: true`. ## Authentication `getAuth(request)` runs on every request. Throw, or return no `userId`, and the request gets `401`. ```ts getAuth: async (req) => { const session = await verifySession(req.headers.get("cookie")); if (!session) throw new Error("no session"); return { userId: session.userId }; }, ``` The `userId` binds each conversation to its user. The agent has no access to your API, so there are no credentials to forward. ## CORS and cookies - A request without an `Origin` header, or from an origin not in `allowedOrigins`, gets `403`. Server-to-server calls and `curl` need an `Origin` header. - Only `POST` and `OPTIONS` are allowed. `Content-Type` must be `application/json`. - If the endpoint is on a different origin than the app and you use cookies, pass `credentials="include"` to `AgentChat`. The default is `same-origin`. ## Rate limits and quotas sideagent limits concurrent turns per user. It does not count requests. Do that in `beforeTurn`. Throw to reject the turn with `429`; the error message goes to the client. ```ts beforeTurn: async ({ userId }) => { if (!(await limiter.allow(userId))) throw new Error("Too many requests. Try again in a minute."); }, ``` `maxConcurrentTurnsPerUser` is counted per process. With several instances, a user can run up to that number on each instance. ## Limits Set under `limits`. Defaults: | Option | Default | Effect when exceeded | |---|---|---| | `maxSteps` | 8 | Turn stops with error code `step_limit` | | `turnTimeoutMs` | 120000 | Turn stops with error code `timeout` | | `maxResultBytes` | 65536 | Passages sent to the model are cut off | | `maxConcurrentTurnsPerUser` | 2 | New turn gets `429` | Fixed limits: request body 256 KB, message 8000 characters. ## Streaming and proxies Responses are server-sent events. Reverse proxies and CDNs must not buffer them, or the user sees no text until the turn ends. Disable buffering for the agent route (for nginx: `proxy_buffering off;`). Set the platform's function timeout above `turnTimeoutMs`. ## Edge and serverless - Pass knowledge as strings. There is no filesystem. - Use persistent stores, because each request can hit a new instance. - Startup waits up to 60 seconds for knowledge to load, then continues loading in the background. Build the agent once per instance, not per request. ## Other things to know - The model gets `instructions` as part of its system prompt. Use it for tone and domain rules, not for security. Keep private material out of `knowledge`. - `context` comes from the browser, so a user can forge it. It is cut to 4000 characters and given to the model as page context. Never use it for access control, and do not put secrets in it. - Assistant replies render as Markdown (GFM) with raw HTML dropped; links open in a new tab with `noopener noreferrer`. Search results are plain text, never HTML. - The model must support tool calling, which the knowledge search uses. `doctor()` checks this. --- # Example Source: https://egig.github.io/sideagent/example A small docs assistant: an agent endpoint over a markdown source and a web page, a React app with the chat docked on the side, and a scripted mock LLM so it runs without an API key. Source: [`demo/`](https://github.com/egig/sideagent/tree/main/demo) ## Run it ``` pnpm install pnpm demo # agent + mock LLM on :8787, UI on http://localhost:5173 ``` Run `pnpm demo:api` for just the agent. To use a real model, set `LLM_API_KEY`, and optionally `LLM_BASE_URL`, `LLM_MODEL` and `EMBEDDING_MODEL`. Try these. The mock LLM matches keywords, not meaning: - "what is your refund policy?" searches the knowledge and shows a Sources card. - "what are the payment terms?" does the same, with the web page as a source. - "hello" is answered without a search. ## Files | File | What it shows | |---|---| | [`demo/server.ts`](https://github.com/egig/sideagent/blob/main/demo/server.ts) | The mock LLM routes, a docs page and the `createAgent` setup | | [`demo/src/main.tsx`](https://github.com/egig/sideagent/blob/main/demo/src/main.tsx) | The React app: `AgentProvider`, `context`, dark mode | | [`demo/vite.config.ts`](https://github.com/egig/sideagent/blob/main/demo/vite.config.ts) | Dev proxy for `/agent` to `:8787` | ## Server walkthrough **Knowledge.** One markdown source and one URL (a payment terms page served by the demo itself). With the mock LLM, embeddings come from a mock endpoint. **Auth.** The demo has no sign-in, so it sets `anonymous: true`. A real app uses `getAuth`. **Startup order.** The server starts listening before `createAgent` runs, because the agent calls the mock LLM, mock embeddings and the docs page on the same process while it starts. Until it is ready, `/agent` returns `503`. ## Client walkthrough ```tsx ({ page: "invoicing guide" })} >
…
``` - `context` tells the agent what the user is looking at. - `theme` follows the app's own dark mode toggle. ## What the example leaves out It is not production setup. It uses in-memory storage, no sign-in, and `allowPrivateNetworks: true` because everything runs on localhost. See [Deployments](./deployments.md) and [Persistence](./persistence.md) for the real setup. --- # Cloudflare Workers Source: https://egig.github.io/sideagent/cloudflare-workers `sideagent/server` runs on Workers. Use `nodejs_compat` (or just `nodejs_als`) in `wrangler.jsonc`. LangGraph relies on `AsyncLocalStorage`, and the Workers build of LangGraph does not register it. sideagent registers it for you when the runtime has it. ```jsonc { "compatibility_date": "2025-09-01", "compatibility_flags": ["nodejs_compat"] } ``` **Export conditions.** `sideagent/server` resolves to the real build under `workerd` and `worker`, listed before `browser` (which stays a throwing stub for real browser bundles). Wrangler (esbuild) and `@cloudflare/vite-plugin` need no alias. Both also set `browser`, so the order in `exports` matters. **Workers AI as the LLM.** `env.AI` is a binding, not an HTTP endpoint, so use the adapter from `sideagent/workers-ai` instead of `llm`: ```ts import { createAgent } from "sideagent/server"; import { workersAiChatModel, workersAiEmbeddings } from "sideagent/workers-ai"; export default { async fetch(request: Request, env: Env) { agent ??= await createAgent({ allowedOrigins: ["https://app.example.com"], getAuth, model: workersAiChatModel(env.AI, "@cf/openai/gpt-oss-120b"), knowledge: { sources }, embeddings: workersAiEmbeddings(env.AI, "@cf/baai/bge-m3"), // replaces knowledge.embeddings }); return agent.handler(request); }, }; let agent: Awaited> | undefined; ``` `model` and `llm` are mutually exclusive; so are the `embeddings` option and `knowledge.embeddings`. `model` can be any LangChain chat model that supports `bindTools()`. `doctor()` checks an injected model with one `invoke`, not an HTTP call. The adapter takes the binding structurally (no `@cloudflare/workers-types` needed) and reads gpt-oss Responses-style output, chat-style `choices[0].message` and `{ response }`. A custom `checkpointer` (D1, Durable Object storage) is a `BaseCheckpointSaver` with four methods: `getTuple`, `list`, `put`, `putWrites`. sideagent also calls `deleteThread` when a user deletes a thread.