sideagent 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,6 +6,8 @@ Embed a chat agent in your app. You give it an OpenAPI spec and an OpenAI-compat
6
6
  npm i sideagent
7
7
  ```
8
8
 
9
+ Docs: https://egig.github.io/sideagent/
10
+
9
11
  ## Server (`sideagent/server`)
10
12
 
11
13
  ```ts
@@ -43,6 +45,16 @@ import { AgentChat } from "sideagent";
43
45
  />
44
46
  ```
45
47
 
48
+ ### sideagent Cloud
49
+
50
+ 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://<host>/v1/agents/<agentId>`) 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.
51
+
52
+ ```tsx
53
+ <AgentChat publishableKey="pk_live_…" />
54
+ ```
55
+
56
+ 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`.
57
+
46
58
  ### Docked sidebar
47
59
 
48
60
  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.
@@ -96,7 +108,7 @@ Neutral by default (grayscale, host font, 10px radius). `theme="auto"` follows t
96
108
 
97
109
  ## Results and icons
98
110
 
99
- Tool results render as readable views: arrays of objects become tables (up to 50 rows and 8 columns, numbers right-aligned, dates and booleans formatted), objects become labelled fields, errors and rejections get their own state. Raw JSON stays under "Details". Everything is plain text, never HTML. Override per operation:
111
+ Tool results render as readable views: arrays of objects become tables (up to 50 rows and 8 columns, numbers right-aligned, dates and booleans formatted), objects become labelled fields, errors and rejections get their own state. Raw JSON stays under "Details". Tool results are plain text, never HTML. Assistant replies render as Markdown (GFM); raw HTML is dropped. Override per operation:
100
112
 
101
113
  ```tsx
102
114
  <AgentChat resultRenderers={{ listInvoices: ({ result }) => <MyInvoiceTable rows={result as any[]} /> }} />
@@ -130,3 +142,45 @@ createAgent({
130
142
  The agent gets a `search_knowledge` tool; answers show a "Sources" card (links only for your configured URLs). Passages reach the model as labelled untrusted data and never reach the browser. URLs come only from config, must be public http(s) (private ranges need `allowPrivateNetworks`, metadata addresses never), follow only same-host redirects, and are capped by `maxSourceBytes`. 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()`.
131
143
 
132
144
  With the default in-memory store, every start re-embeds all sources (embedding cost). Use a persistent `vectorStore` for large knowledge.
145
+
146
+ ## Cloudflare Workers
147
+
148
+ `sideagent/server` runs on Workers. Use `nodejs_compat` (or just `nodejs_als`) in `wrangler.jsonc`. LangGraph's approval pause (`interrupt()`) needs `AsyncLocalStorage`, and the Workers build of LangGraph does not register it. sideagent registers it for you when the runtime has it. Without the flag, reads work and the first approval fails (startup logs a warning).
149
+
150
+ ```jsonc
151
+ { "compatibility_date": "2025-09-01", "compatibility_flags": ["nodejs_compat"] }
152
+ ```
153
+
154
+ **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.
155
+
156
+ **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`:
157
+
158
+ ```ts
159
+ import { createAgent } from "sideagent/server";
160
+ import { workersAiChatModel, workersAiEmbeddings } from "sideagent/workers-ai";
161
+
162
+ export default {
163
+ async fetch(request: Request, env: Env) {
164
+ agent ??= await createAgent({
165
+ spec, baseUrl: env.API_URL, allow: ["listInvoices", "createInvoice"],
166
+ allowedOrigins: ["https://app.example.com"], approvalSecret: env.APPROVAL_SECRET,
167
+ getAuth,
168
+ model: workersAiChatModel(env.AI, "@cf/openai/gpt-oss-120b"),
169
+ knowledge: { sources }, // optional
170
+ embeddings: workersAiEmbeddings(env.AI, "@cf/baai/bge-m3"), // replaces knowledge.embeddings
171
+ });
172
+ return agent.handler(request);
173
+ },
174
+ };
175
+ let agent: Awaited<ReturnType<typeof createAgent>> | undefined;
176
+ ```
177
+
178
+ `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 }`.
179
+
180
+ 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.
181
+
182
+ ## For AI agents
183
+
184
+ - Skill: `npx skills add egig/sideagent`, then ask your coding agent to "add an AI assistant to this app". See [Quickstart](https://egig.github.io/sideagent/quickstart).
185
+ - [`AGENTS.md`](AGENTS.md): commands, layout and rules for coding agents working on this repo.
186
+ - [`llms.txt`](https://egig.github.io/sideagent/llms.txt) and [`llms-full.txt`](https://egig.github.io/sideagent/llms-full.txt): the docs in LLM-friendly form.
package/dist/index.js CHANGED
@@ -25,6 +25,8 @@ import {
25
25
  ViewVerticalIcon
26
26
  } from "@radix-ui/react-icons";
27
27
  import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
28
+ import Markdown from "react-markdown";
29
+ import remarkGfm from "remark-gfm";
28
30
 
29
31
  // src/client/hooks.ts
30
32
  function safely(fn) {
@@ -166,6 +168,18 @@ var CSS = `
166
168
  .sa-msg{max-width:90%;white-space:pre-wrap;word-break:break-word}
167
169
  .sa-user{align-self:flex-end;background:var(--sa-surface);padding:7px 12px;border-radius:calc(var(--sa-radius) + 4px)}
168
170
  .sa-assistant{align-self:flex-start}
171
+ .sa-md{white-space:normal;min-width:0}
172
+ .sa-md>:first-child{margin-top:0}.sa-md>:last-child{margin-bottom:0}
173
+ .sa-md :is(p,ul,ol,pre,table,blockquote){margin:0 0 .6em}
174
+ .sa-md :is(ul,ol){padding-left:1.4em}
175
+ .sa-md :is(h1,h2,h3,h4){font-size:1em;font-weight:600;margin:.8em 0 .3em}
176
+ .sa-md a{color:inherit;text-decoration:underline}
177
+ .sa-md code{font-family:ui-monospace,monospace;font-size:.9em;background:var(--sa-surface);padding:1px 4px;border-radius:4px}
178
+ .sa-md pre{background:var(--sa-surface);padding:8px 10px;border-radius:calc(var(--sa-radius) - 2px);overflow-x:auto}
179
+ .sa-md pre code{background:none;padding:0}
180
+ .sa-md blockquote{border-left:3px solid var(--sa-border);padding-left:10px;color:var(--sa-muted);margin-left:0}
181
+ .sa-md table{border-collapse:collapse;display:block;overflow-x:auto}
182
+ .sa-md :is(th,td){border:1px solid var(--sa-border);padding:3px 8px;text-align:left}
169
183
  .sa-icon-btn{display:inline-grid;place-items:center;min-width:28px;padding:4px}
170
184
  .sa-icon-btn svg{width:15px;height:15px}
171
185
  .sa-tool{align-self:stretch;border:1px solid var(--sa-border);border-radius:var(--sa-radius);padding:8px 10px;font-size:13px;min-width:0}
@@ -402,12 +416,15 @@ function ToolCard(props) {
402
416
  ] })
403
417
  ] });
404
418
  }
419
+ var mdComponents = {
420
+ a: ({ node: _n, ...p }) => /* @__PURE__ */ jsx2("a", { ...p, target: "_blank", rel: "noopener noreferrer nofollow" })
421
+ };
405
422
  var UserMessage = () => /* @__PURE__ */ jsx2(MessagePrimitive.Root, { className: "sa-msg sa-user", children: /* @__PURE__ */ jsx2(MessagePrimitive.Parts, { components: { Text } }) });
406
423
  var AssistantMessage = () => /* @__PURE__ */ jsx2(MessagePrimitive.Root, { style: { display: "contents" }, children: /* @__PURE__ */ jsx2(
407
424
  MessagePrimitive.Parts,
408
425
  {
409
426
  components: {
410
- Text: ({ text }) => text ? /* @__PURE__ */ jsx2("div", { className: "sa-msg sa-assistant", children: text }) : null,
427
+ Text: ({ text }) => text ? /* @__PURE__ */ jsx2("div", { className: "sa-msg sa-assistant sa-md", children: /* @__PURE__ */ jsx2(Markdown, { remarkPlugins: [remarkGfm], components: mdComponents, children: text }) }) : null,
411
428
  tools: { Fallback: ToolCard }
412
429
  }
413
430
  }
@@ -2,16 +2,17 @@ import { ThreadMeta } from '../protocol.js';
2
2
  export { AgentEvent, ChatRequest } from '../protocol.js';
3
3
  import { z } from 'zod';
4
4
  import { BaseCheckpointSaver } from '@langchain/langgraph';
5
+ import { BaseChatModel } from '@langchain/core/language_models/chat_models';
5
6
 
6
7
  declare const configSchema: z.ZodObject<{
7
8
  spec: z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodUnknown>]>;
8
9
  baseUrl: z.ZodString;
9
- llm: z.ZodObject<{
10
+ llm: z.ZodOptional<z.ZodObject<{
10
11
  baseUrl: z.ZodString;
11
12
  apiKey: z.ZodString;
12
13
  model: z.ZodString;
13
14
  temperature: z.ZodOptional<z.ZodNumber>;
14
- }, z.core.$strip>;
15
+ }, z.core.$strip>>;
15
16
  allow: z.ZodArray<z.ZodString>;
16
17
  allowedOrigins: z.ZodArray<z.ZodString>;
17
18
  anonymous: z.ZodOptional<z.ZodBoolean>;
@@ -28,11 +29,11 @@ declare const configSchema: z.ZodObject<{
28
29
  url: z.ZodString;
29
30
  title: z.ZodOptional<z.ZodString>;
30
31
  }, z.core.$strip>]>>>;
31
- embeddings: z.ZodObject<{
32
+ embeddings: z.ZodOptional<z.ZodObject<{
32
33
  model: z.ZodString;
33
34
  baseUrl: z.ZodOptional<z.ZodString>;
34
35
  apiKey: z.ZodOptional<z.ZodString>;
35
- }, z.core.$strip>;
36
+ }, z.core.$strip>>;
36
37
  topK: z.ZodDefault<z.ZodNumber>;
37
38
  ttlMs: z.ZodDefault<z.ZodNumber>;
38
39
  maxSourceBytes: z.ZodDefault<z.ZodNumber>;
@@ -51,7 +52,10 @@ declare const configSchema: z.ZodObject<{
51
52
  type AgentConfigInput = z.input<typeof configSchema>;
52
53
  type AgentConfig = z.output<typeof configSchema>;
53
54
  /** Validates config; also requires getAuth or anonymous:true (checked by caller with the function in hand). */
54
- declare function parseConfig(input: unknown, hasGetAuth: boolean): AgentConfig;
55
+ declare function parseConfig(input: unknown, hasGetAuth: boolean, injected?: {
56
+ model?: boolean;
57
+ embeddings?: boolean;
58
+ }): AgentConfig;
55
59
 
56
60
  interface UrlGuardOptions {
57
61
  /** Pinned origin from config. Requests may only go here. */
@@ -187,7 +191,16 @@ interface AuthResult {
187
191
  /** Headers forwarded to your API (e.g. Authorization, Cookie). Never shown to the LLM. */
188
192
  headers?: Record<string, string>;
189
193
  }
194
+ /** Same shape as LangChain's Embeddings, minimal so any adapter fits. */
195
+ interface EmbeddingsLike {
196
+ embedDocuments(texts: string[]): Promise<number[][]>;
197
+ embedQuery(text: string): Promise<number[]>;
198
+ }
190
199
  interface CreateAgentOptions extends AgentConfigInput {
200
+ /** Your own chat model (e.g. workersAiChatModel from sideagent/workers-ai). Use instead of `llm`, never with it. It must support bindTools(). */
201
+ model?: BaseChatModel;
202
+ /** Your own embeddings, used instead of knowledge.embeddings. */
203
+ embeddings?: EmbeddingsLike;
191
204
  getAuth?: (request: Request) => AuthResult | Promise<AuthResult>;
192
205
  /** Throw to reject a turn (rate limiting, quota). */
193
206
  beforeTurn?: (ctx: {
@@ -244,4 +257,4 @@ declare function honoHandler(agent: Agent): (c: {
244
257
  /** Express (use express.json() before it): `app.post("/agent", expressHandler(agent))` and `app.options("/agent", expressHandler(agent))` */
245
258
  declare function expressHandler(agent: Agent): (req: any, res: any) => Promise<any>;
246
259
 
247
- export { type Agent, type AgentConfig, type AgentConfigInput, type AuditEvent, type AuthResult, type CreateAgentOptions, type DoctorReport, KnowledgeBase, type KnowledgeSourceInput, MemoryThreadStore, MemoryVectorStore, type ThreadStore, type VectorStore, assertAllowedUrl, chunkText, configSchema, createAgent, expressHandler, honoHandler, htmlToText, nextRoute, parseConfig };
260
+ export { type Agent, type AgentConfig, type AgentConfigInput, type AuditEvent, type AuthResult, type CreateAgentOptions, type DoctorReport, type EmbeddingsLike, KnowledgeBase, type KnowledgeSourceInput, MemoryThreadStore, MemoryVectorStore, type ThreadStore, type VectorStore, assertAllowedUrl, chunkText, configSchema, createAgent, expressHandler, honoHandler, htmlToText, nextRoute, parseConfig };
@@ -6,8 +6,8 @@ var knowledgeSchema = z.object({
6
6
  sources: z.array(z.object({ id: z.string().min(1).max(64), title: z.string().min(1).max(200), content: z.string() })).default([]),
7
7
  /** Pages the server fetches itself (never model-chosen). Public hosts only unless allowPrivateNetworks. */
8
8
  urls: z.array(z.union([url, z.object({ url, title: z.string().max(200).optional() })])).default([]),
9
- /** OpenAI-compatible /embeddings endpoint. baseUrl and apiKey default to the llm ones. */
10
- embeddings: z.object({ model: z.string().min(1), baseUrl: url.optional(), apiKey: z.string().min(1).optional() }),
9
+ /** OpenAI-compatible /embeddings endpoint. baseUrl and apiKey default to the llm ones. Omit when you pass the `embeddings` option to createAgent. */
10
+ embeddings: z.object({ model: z.string().min(1), baseUrl: url.optional(), apiKey: z.string().min(1).optional() }).optional(),
11
11
  topK: z.number().int().min(1).max(12).default(4),
12
12
  ttlMs: z.number().int().positive().default(36e5),
13
13
  maxSourceBytes: z.number().int().positive().default(2e5),
@@ -17,12 +17,13 @@ var knowledgeSchema = z.object({
17
17
  var configSchema = z.object({
18
18
  spec: z.union([z.string(), z.record(z.string(), z.unknown())]),
19
19
  baseUrl: url,
20
+ /** OpenAI-compatible endpoint. Give either this or the `model` option of createAgent, never both. */
20
21
  llm: z.object({
21
22
  baseUrl: url,
22
23
  apiKey: z.string().min(1, "llm.apiKey is empty; read it from an env var on the server"),
23
24
  model: z.string().min(1),
24
25
  temperature: z.number().min(0).max(2).optional()
25
- }),
26
+ }).optional(),
26
27
  allow: z.array(z.string()).min(1, "allow is empty; list operationIds, tags or globs to expose"),
27
28
  allowedOrigins: z.array(z.string().url()).min(1, "allowedOrigins is required"),
28
29
  anonymous: z.boolean().optional(),
@@ -47,7 +48,7 @@ var configSchema = z.object({
47
48
  maxTools: 40
48
49
  })
49
50
  }).strict();
50
- function parseConfig(input, hasGetAuth) {
51
+ function parseConfig(input, hasGetAuth, injected = {}) {
51
52
  const result = configSchema.safeParse(input);
52
53
  if (!result.success) {
53
54
  const lines = result.error.issues.map((i) => ` - ${i.path.join(".") || "(root)"}: ${i.message}`);
@@ -57,6 +58,18 @@ ${lines.join("\n")}`);
57
58
  if (!hasGetAuth && !result.data.anonymous) {
58
59
  throw new Error("Invalid agent config:\n - getAuth: provide getAuth(request) or set anonymous: true explicitly");
59
60
  }
61
+ const { llm, knowledge } = result.data;
62
+ const fail = (msg) => {
63
+ throw new Error(`Invalid agent config:
64
+ - ${msg}`);
65
+ };
66
+ if (llm && injected.model) fail("llm and model are mutually exclusive: pass llm for an OpenAI-compatible endpoint, or model for your own chat model");
67
+ if (!llm && !injected.model) fail("no model: pass llm ({ baseUrl, apiKey, model }) or model (a LangChain BaseChatModel)");
68
+ if (knowledge?.embeddings && injected.embeddings) fail("knowledge.embeddings and the embeddings option are mutually exclusive");
69
+ if (knowledge && !knowledge.embeddings && !injected.embeddings) fail("knowledge needs knowledge.embeddings (HTTP endpoint) or the embeddings option");
70
+ if (knowledge?.embeddings && !llm && !(knowledge.embeddings.baseUrl && knowledge.embeddings.apiKey)) {
71
+ fail("knowledge.embeddings needs baseUrl and apiKey when llm is not set");
72
+ }
60
73
  return result.data;
61
74
  }
62
75
 
@@ -112,7 +125,7 @@ function assertPublicUrl(url2, allowPrivateNetworks) {
112
125
  // src/server/create-agent.ts
113
126
  import { HumanMessage } from "@langchain/core/messages";
114
127
  import { Command as Command2 } from "@langchain/langgraph";
115
- import { OpenAIEmbeddings } from "@langchain/openai";
128
+ import { ChatOpenAI, OpenAIEmbeddings } from "@langchain/openai";
116
129
  import { z as z2 } from "zod";
117
130
 
118
131
  // src/server/approval.ts
@@ -152,7 +165,6 @@ async function verifyApproval(secret, token, claims) {
152
165
 
153
166
  // src/server/graph.ts
154
167
  import { SystemMessage, ToolMessage } from "@langchain/core/messages";
155
- import { ChatOpenAI } from "@langchain/openai";
156
168
  import {
157
169
  Command,
158
170
  END,
@@ -195,8 +207,8 @@ async function readCapped(res, max) {
195
207
  let size = 0;
196
208
  let truncated = false;
197
209
  for (; ; ) {
198
- const { done, value } = await reader.read();
199
- if (done) break;
210
+ const { done: done2, value } = await reader.read();
211
+ if (done2) break;
200
212
  size += value.byteLength;
201
213
  if (size > max) {
202
214
  truncated = true;
@@ -606,7 +618,7 @@ function redact(value) {
606
618
 
607
619
  // src/server/graph.ts
608
620
  var APPROVAL_TTL_MS = 10 * 6e4;
609
- function buildGraph({ config, ops, secret, checkpointer, onAudit, knowledge }) {
621
+ function buildGraph({ config, ops, secret, checkpointer, onAudit, knowledge, model: baseModel, bindOptions }) {
610
622
  const byName = new Map(ops.map((o) => [o.operationId, o]));
611
623
  const toolDefs = ops.map((o) => ({
612
624
  type: "function",
@@ -623,13 +635,8 @@ function buildGraph({ config, ops, secret, checkpointer, onAudit, knowledge }) {
623
635
  }
624
636
  });
625
637
  }
626
- const model = new ChatOpenAI({
627
- model: config.llm.model,
628
- apiKey: config.llm.apiKey,
629
- temperature: config.llm.temperature,
630
- streaming: true,
631
- configuration: { baseURL: config.llm.baseUrl }
632
- }).bindTools(toolDefs, { parallel_tool_calls: false });
638
+ if (!baseModel.bindTools) throw new Error("The chat model does not support bindTools(), which sideagent needs for tool calling.");
639
+ const model = baseModel.bindTools(toolDefs, bindOptions);
633
640
  const audit = (e) => {
634
641
  try {
635
642
  void Promise.resolve(onAudit?.({ ...e, args: redact(e.args) })).catch(() => {
@@ -768,6 +775,31 @@ function buildGraph({ config, ops, secret, checkpointer, onAudit, knowledge }) {
768
775
  return new StateGraph(MessagesAnnotation).addNode("agent", agentNode).addNode("tools", toolsNode).addEdge(START, "agent").addConditionalEdges("agent", route, ["tools", END]).addEdge("tools", "agent").compile({ checkpointer: checkpointer ?? new MemorySaver() });
769
776
  }
770
777
 
778
+ // src/server/als.ts
779
+ import { AsyncLocalStorageProviderSingleton } from "@langchain/core/singletons";
780
+ var done;
781
+ function ensureAsyncLocalStorage() {
782
+ done ??= (async () => {
783
+ const spec = "node:async_hooks";
784
+ let ALS = globalThis.AsyncLocalStorage;
785
+ if (!ALS) {
786
+ try {
787
+ ALS = (await import(
788
+ /* @vite-ignore */
789
+ spec
790
+ )).AsyncLocalStorage;
791
+ } catch {
792
+ console.warn(
793
+ '[sideagent] AsyncLocalStorage is unavailable, so approval pauses will fail. On Cloudflare Workers add "nodejs_compat" (or "nodejs_als") to compatibility_flags.'
794
+ );
795
+ return;
796
+ }
797
+ }
798
+ AsyncLocalStorageProviderSingleton.initializeGlobalInstance(new ALS());
799
+ })();
800
+ return done;
801
+ }
802
+
771
803
  // src/server/history.ts
772
804
  function textOf(content) {
773
805
  if (typeof content === "string") return content;
@@ -844,13 +876,15 @@ var chatBody = z2.union([
844
876
  })
845
877
  ]);
846
878
  var enc2 = encodeURIComponent;
879
+ var PING_TOOL = { type: "function", function: { name: "ping", description: "ping", parameters: { type: "object", properties: {} } } };
847
880
  var MAX_BODY = 256 * 1024;
848
881
  function json(status, body, extra = {}) {
849
882
  return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json", ...extra } });
850
883
  }
851
884
  async function createAgent(options) {
852
- const { getAuth, beforeTurn, onAudit, checkpointer, threadStore: givenStore, vectorStore, ...data } = options;
853
- const config = parseConfig(data, !!getAuth);
885
+ const { getAuth, beforeTurn, onAudit, checkpointer, threadStore: givenStore, vectorStore, model: givenModel, embeddings: givenEmbeddings, ...data } = options;
886
+ await ensureAsyncLocalStorage();
887
+ const config = parseConfig(data, !!getAuth, { model: !!givenModel, embeddings: !!givenEmbeddings });
854
888
  const ops = await loadOperations(config.spec, config.allow, config.limits.maxTools);
855
889
  const secret = config.approvalSecret ?? crypto.randomUUID() + crypto.randomUUID();
856
890
  if (!config.approvalSecret) {
@@ -869,7 +903,7 @@ async function createAgent(options) {
869
903
  throw new Error(`An allowlisted operation is named "${SEARCH_TOOL}", which is reserved for knowledge search`);
870
904
  }
871
905
  const k = config.knowledge;
872
- const embedder = new OpenAIEmbeddings({
906
+ const embedder = givenEmbeddings ?? new OpenAIEmbeddings({
873
907
  model: k.embeddings.model,
874
908
  apiKey: k.embeddings.apiKey ?? config.llm.apiKey,
875
909
  configuration: { baseURL: k.embeddings.baseUrl ?? config.llm.baseUrl }
@@ -884,7 +918,9 @@ async function createAgent(options) {
884
918
  const timedOut = await Promise.race([loading.then(() => false), new Promise((r) => setTimeout(() => r(true), 6e4))]);
885
919
  if (timedOut) console.warn("[sideagent] knowledge is still loading after 60s; search results may be incomplete until it finishes.");
886
920
  }
887
- const graph = buildGraph({ config, ops, secret, checkpointer, onAudit, knowledge });
921
+ const llm = config.llm;
922
+ const model = givenModel ?? new ChatOpenAI({ model: llm.model, apiKey: llm.apiKey, temperature: llm.temperature, streaming: true, configuration: { baseURL: llm.baseUrl } });
923
+ const graph = buildGraph({ config, ops, secret, checkpointer, onAudit, knowledge, model, bindOptions: givenModel ? void 0 : { parallel_tool_calls: false } });
888
924
  const active = /* @__PURE__ */ new Map();
889
925
  async function handler(request) {
890
926
  const origin = request.headers.get("origin");
@@ -1016,31 +1052,42 @@ async function createAgent(options) {
1016
1052
  headers: { ...cors, "content-type": "text/event-stream", "cache-control": "no-store" }
1017
1053
  });
1018
1054
  }
1019
- async function doctor() {
1020
- const checks = [];
1021
- checks.push({ name: "spec", ok: true, detail: `${ops.length} operation(s) exposed` });
1022
- if (secret === config.approvalSecret) checks.push({ name: "approvalSecret", ok: true, detail: "set" });
1023
- else checks.push({ name: "approvalSecret", ok: false, detail: "not set; random per instance" });
1055
+ async function checkInjectedModel(m) {
1056
+ try {
1057
+ if (!m.bindTools) return { name: "llm tool calling", ok: false, detail: "model has no bindTools()" };
1058
+ const reply = await m.bindTools([PING_TOOL]).invoke("Call the ping tool.");
1059
+ const called = (reply.tool_calls?.length ?? 0) > 0;
1060
+ return { name: "llm tool calling", ok: called, detail: called ? "supported" : "model did not return a tool call" };
1061
+ } catch (e) {
1062
+ return { name: "llm", ok: false, detail: e.message };
1063
+ }
1064
+ }
1065
+ async function checkHttpLlm(cfg) {
1024
1066
  try {
1025
- const res = await fetch(`${config.llm.baseUrl.replace(/\/$/, "")}/chat/completions`, {
1067
+ const res = await fetch(`${cfg.baseUrl.replace(/\/$/, "")}/chat/completions`, {
1026
1068
  method: "POST",
1027
- headers: { "content-type": "application/json", authorization: `Bearer ${config.llm.apiKey}` },
1069
+ headers: { "content-type": "application/json", authorization: `Bearer ${cfg.apiKey}` },
1028
1070
  body: JSON.stringify({
1029
- model: config.llm.model,
1071
+ model: cfg.model,
1030
1072
  messages: [{ role: "user", content: "Call the ping tool." }],
1031
- tools: [{ type: "function", function: { name: "ping", description: "ping", parameters: { type: "object", properties: {} } } }],
1073
+ tools: [PING_TOOL],
1032
1074
  tool_choice: { type: "function", function: { name: "ping" } }
1033
1075
  })
1034
1076
  });
1035
- if (!res.ok) checks.push({ name: "llm", ok: false, detail: `HTTP ${res.status}` });
1036
- else {
1037
- const j = await res.json();
1038
- const called = j?.choices?.[0]?.message?.tool_calls?.length > 0;
1039
- checks.push({ name: "llm tool calling", ok: called, detail: called ? "supported" : "model did not return a tool call" });
1040
- }
1077
+ if (!res.ok) return { name: "llm", ok: false, detail: `HTTP ${res.status}` };
1078
+ const j = await res.json();
1079
+ const called = j?.choices?.[0]?.message?.tool_calls?.length > 0;
1080
+ return { name: "llm tool calling", ok: called, detail: called ? "supported" : "model did not return a tool call" };
1041
1081
  } catch (e) {
1042
- checks.push({ name: "llm", ok: false, detail: e.message });
1082
+ return { name: "llm", ok: false, detail: e.message };
1043
1083
  }
1084
+ }
1085
+ async function doctor() {
1086
+ const checks = [];
1087
+ checks.push({ name: "spec", ok: true, detail: `${ops.length} operation(s) exposed` });
1088
+ if (secret === config.approvalSecret) checks.push({ name: "approvalSecret", ok: true, detail: "set" });
1089
+ else checks.push({ name: "approvalSecret", ok: false, detail: "not set; random per instance" });
1090
+ checks.push(await (givenModel ? checkInjectedModel(givenModel) : checkHttpLlm(config.llm)));
1044
1091
  if (knowledge) {
1045
1092
  const st = knowledge.status();
1046
1093
  const bad = st.filter((x) => x.error);
@@ -1090,8 +1137,8 @@ function expressHandler(agent) {
1090
1137
  if (!r.body) return res.end();
1091
1138
  const reader = r.body.getReader();
1092
1139
  for (; ; ) {
1093
- const { done, value } = await reader.read();
1094
- if (done) break;
1140
+ const { done: done2, value } = await reader.read();
1141
+ if (done2) break;
1095
1142
  res.write(value);
1096
1143
  }
1097
1144
  res.end();
@@ -0,0 +1,56 @@
1
+ import { BaseChatModel, BaseChatModelCallOptions, BindToolsInput } from '@langchain/core/language_models/chat_models';
2
+ import { BaseLanguageModelInput } from '@langchain/core/language_models/base';
3
+ import { CallbackManagerForLLMRun } from '@langchain/core/callbacks/manager';
4
+ import { AIMessageChunk, BaseMessage } from '@langchain/core/messages';
5
+ import { ChatResult, ChatGenerationChunk } from '@langchain/core/outputs';
6
+ import { Runnable } from '@langchain/core/runnables';
7
+
8
+ /**
9
+ * The part of the Workers AI binding (`env.AI`) used here. Structural on purpose:
10
+ * no dependency on @cloudflare/workers-types.
11
+ */
12
+ interface WorkersAiBinding {
13
+ run(model: string, input: unknown): Promise<unknown>;
14
+ }
15
+ interface WorkersAiChatOptions {
16
+ temperature?: number;
17
+ maxTokens?: number;
18
+ /** Ask the binding for a stream. Falls back to one chunk when it returns a plain object. Default true. */
19
+ streaming?: boolean;
20
+ }
21
+ interface WorkersAiCallOptions extends BaseChatModelCallOptions {
22
+ tools?: unknown[];
23
+ }
24
+ interface ToolCall {
25
+ id: string;
26
+ name: string;
27
+ args: Record<string, unknown>;
28
+ }
29
+ interface Parsed {
30
+ text: string;
31
+ toolCalls: ToolCall[];
32
+ }
33
+ /** Reads every response shape the binding is known to return. Throws on anything else. */
34
+ declare function parseWorkersAiResponse(res: unknown): Parsed;
35
+ declare class ChatWorkersAI extends BaseChatModel<WorkersAiCallOptions> {
36
+ private readonly ai;
37
+ private readonly modelName;
38
+ private readonly opts;
39
+ constructor(ai: WorkersAiBinding, modelName: string, opts?: WorkersAiChatOptions);
40
+ _llmType(): string;
41
+ bindTools(tools: BindToolsInput[], kwargs?: Partial<WorkersAiCallOptions>): Runnable<BaseLanguageModelInput, AIMessageChunk, WorkersAiCallOptions>;
42
+ private input;
43
+ private run;
44
+ _generate(messages: BaseMessage[], options: this["ParsedCallOptions"]): Promise<ChatResult>;
45
+ _streamResponseChunks(messages: BaseMessage[], options: this["ParsedCallOptions"], runManager?: CallbackManagerForLLMRun): AsyncGenerator<ChatGenerationChunk>;
46
+ private emit;
47
+ }
48
+ /** A LangChain chat model backed by the Workers AI binding. Pass it to createAgent as `model`. */
49
+ declare function workersAiChatModel(ai: WorkersAiBinding, model: string, opts?: WorkersAiChatOptions): BaseChatModel;
50
+ /** Embeddings backed by the Workers AI binding. Pass them to createAgent as `embeddings`. */
51
+ declare function workersAiEmbeddings(ai: WorkersAiBinding, model: string): {
52
+ embedDocuments: (texts: string[]) => Promise<number[][]>;
53
+ embedQuery(text: string): Promise<number[]>;
54
+ };
55
+
56
+ export { ChatWorkersAI, type WorkersAiBinding, type WorkersAiChatOptions, parseWorkersAiResponse, workersAiChatModel, workersAiEmbeddings };
@@ -0,0 +1,241 @@
1
+ // src/workers-ai/index.ts
2
+ import { BaseChatModel } from "@langchain/core/language_models/chat_models";
3
+ import { AIMessage, AIMessageChunk } from "@langchain/core/messages";
4
+ import { ChatGenerationChunk } from "@langchain/core/outputs";
5
+ import { convertToOpenAITool } from "@langchain/core/utils/function_calling";
6
+ var isObj = (v) => !!v && typeof v === "object" && !Array.isArray(v);
7
+ function textOf(content) {
8
+ if (typeof content === "string") return content;
9
+ return content.map((p) => p.type === "text" && typeof p.text === "string" ? p.text : "").join("");
10
+ }
11
+ function toWire(messages) {
12
+ return messages.map((m) => {
13
+ const type = m.getType();
14
+ const content = textOf(m.content);
15
+ if (type === "system") return { role: "system", content };
16
+ if (type === "human") return { role: "user", content };
17
+ if (type === "tool") return { role: "tool", tool_call_id: m.tool_call_id, content };
18
+ const calls = m.tool_calls ?? [];
19
+ return {
20
+ role: "assistant",
21
+ content,
22
+ ...calls.length ? { tool_calls: calls.map((c) => ({ id: c.id, type: "function", function: { name: c.name, arguments: JSON.stringify(c.args ?? {}) } })) } : {}
23
+ };
24
+ });
25
+ }
26
+ function parseArgs(raw) {
27
+ if (raw == null || raw === "") return {};
28
+ if (isObj(raw)) return raw;
29
+ if (typeof raw === "string") {
30
+ let v;
31
+ try {
32
+ v = JSON.parse(raw);
33
+ } catch {
34
+ throw new Error("Workers AI returned a tool call with malformed arguments");
35
+ }
36
+ if (isObj(v)) return v;
37
+ }
38
+ throw new Error("Workers AI returned a tool call with malformed arguments");
39
+ }
40
+ var newId = () => `call_${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
41
+ function parseWorkersAiResponse(res) {
42
+ const r = isObj(res) && isObj(res.result) && !("response" in res) && !("choices" in res) && !("output" in res) ? res.result : res;
43
+ if (typeof r === "string") return { text: r, toolCalls: [] };
44
+ if (!isObj(r)) throw new Error("Workers AI returned an unrecognized response");
45
+ let text = "";
46
+ const toolCalls = [];
47
+ let known = false;
48
+ if (Array.isArray(r.output)) {
49
+ known = true;
50
+ for (const item of r.output) {
51
+ if (!isObj(item)) continue;
52
+ if (item.type === "function_call") {
53
+ toolCalls.push({ id: String(item.call_id ?? item.id ?? newId()), name: String(item.name), args: parseArgs(item.arguments) });
54
+ } else if (item.type === "message" || item.type === void 0 && Array.isArray(item.content)) {
55
+ for (const part of Array.isArray(item.content) ? item.content : []) {
56
+ if (isObj(part) && (part.type === "output_text" || part.type === "text") && typeof part.text === "string") text += part.text;
57
+ }
58
+ }
59
+ }
60
+ }
61
+ const msg = Array.isArray(r.choices) && isObj(r.choices[0]) ? r.choices[0].message : void 0;
62
+ if (isObj(msg)) {
63
+ known = true;
64
+ if (typeof msg.content === "string") text += msg.content;
65
+ for (const c of Array.isArray(msg.tool_calls) ? msg.tool_calls : []) {
66
+ const fn = isObj(c) ? c.function ?? c : void 0;
67
+ if (!isObj(fn) || typeof fn.name !== "string") throw new Error("Workers AI returned a malformed tool call");
68
+ toolCalls.push({ id: String(c.id ?? newId()), name: fn.name, args: parseArgs(fn.arguments) });
69
+ }
70
+ }
71
+ if (typeof r.response === "string") {
72
+ known = true;
73
+ text += r.response;
74
+ } else if (r.response === null) known = true;
75
+ if (Array.isArray(r.tool_calls)) {
76
+ known = true;
77
+ for (const c of r.tool_calls) {
78
+ const fn = isObj(c) ? c.function ?? c : void 0;
79
+ if (!isObj(fn) || typeof fn.name !== "string") throw new Error("Workers AI returned a malformed tool call");
80
+ toolCalls.push({ id: String(c.id ?? newId()), name: fn.name, args: parseArgs(fn.arguments) });
81
+ }
82
+ }
83
+ if (!known) throw new Error("Workers AI returned an unrecognized response");
84
+ return { text, toolCalls };
85
+ }
86
+ async function* sseEvents(stream) {
87
+ const dec = new TextDecoder();
88
+ let buf = "";
89
+ const source = Symbol.asyncIterator in stream ? stream : readerIterable(stream);
90
+ function* drain(final) {
91
+ const lines = buf.split("\n");
92
+ buf = final ? "" : lines.pop() ?? "";
93
+ for (const line of lines) {
94
+ const l = line.trim();
95
+ if (!l.startsWith("data:")) continue;
96
+ const data = l.slice(5).trim();
97
+ if (!data || data === "[DONE]") continue;
98
+ try {
99
+ yield JSON.parse(data);
100
+ } catch {
101
+ throw new Error("Workers AI returned a malformed stream event");
102
+ }
103
+ }
104
+ }
105
+ for await (const chunk of source) {
106
+ buf += typeof chunk === "string" ? chunk : dec.decode(chunk, { stream: true });
107
+ yield* drain(false);
108
+ }
109
+ yield* drain(true);
110
+ }
111
+ async function* readerIterable(stream) {
112
+ const reader = stream.getReader();
113
+ try {
114
+ for (; ; ) {
115
+ const { done, value } = await reader.read();
116
+ if (done) return;
117
+ yield value;
118
+ }
119
+ } finally {
120
+ reader.releaseLock();
121
+ }
122
+ }
123
+ var isStream = (v) => !!v && typeof v === "object" && (typeof v.getReader === "function" || Symbol.asyncIterator in v);
124
+ var ChatWorkersAI = class extends BaseChatModel {
125
+ constructor(ai, modelName, opts = {}) {
126
+ super({});
127
+ this.ai = ai;
128
+ this.modelName = modelName;
129
+ this.opts = opts;
130
+ }
131
+ ai;
132
+ modelName;
133
+ opts;
134
+ _llmType() {
135
+ return "workers-ai";
136
+ }
137
+ bindTools(tools, kwargs) {
138
+ return this.withConfig({ ...kwargs, tools: tools.map((t) => convertToOpenAITool(t)) });
139
+ }
140
+ input(messages, options, stream) {
141
+ return {
142
+ messages: toWire(messages),
143
+ ...options.tools?.length ? { tools: options.tools } : {},
144
+ ...this.opts.temperature !== void 0 ? { temperature: this.opts.temperature } : {},
145
+ ...this.opts.maxTokens !== void 0 ? { max_tokens: this.opts.maxTokens } : {},
146
+ ...stream ? { stream: true } : {}
147
+ };
148
+ }
149
+ async run(input, signal) {
150
+ signal?.throwIfAborted();
151
+ return this.ai.run(this.modelName, input);
152
+ }
153
+ async _generate(messages, options) {
154
+ const { text, toolCalls } = parseWorkersAiResponse(await this.run(this.input(messages, options, false), options.signal));
155
+ const message = new AIMessage({ content: text, tool_calls: toolCalls.map((c) => ({ type: "tool_call", ...c })) });
156
+ return { generations: [{ text, message }] };
157
+ }
158
+ async *_streamResponseChunks(messages, options, runManager) {
159
+ const res = this.opts.streaming === false ? void 0 : await this.run(this.input(messages, options, true), options.signal);
160
+ if (!isStream(res)) {
161
+ const full = res === void 0 ? parseWorkersAiResponse(await this.run(this.input(messages, options, false), options.signal)) : parseWorkersAiResponse(res);
162
+ yield* this.emit(full, runManager);
163
+ return;
164
+ }
165
+ const partial = /* @__PURE__ */ new Map();
166
+ const whole = [];
167
+ for await (const ev of sseEvents(res)) {
168
+ options.signal?.throwIfAborted();
169
+ if (!isObj(ev)) continue;
170
+ let delta = "";
171
+ if (typeof ev.response === "string") delta = ev.response;
172
+ const choice = Array.isArray(ev.choices) && isObj(ev.choices[0]) ? ev.choices[0] : void 0;
173
+ if (choice) {
174
+ const d = isObj(choice.delta) ? choice.delta : isObj(choice.message) ? choice.message : {};
175
+ if (typeof d.content === "string") delta += d.content;
176
+ for (const tc of Array.isArray(d.tool_calls) ? d.tool_calls : []) {
177
+ const i = typeof tc.index === "number" ? tc.index : partial.size;
178
+ const cur = partial.get(i) ?? { name: "", args: "" };
179
+ if (tc.id) cur.id = String(tc.id);
180
+ if (tc.function?.name) cur.name += tc.function.name;
181
+ if (typeof tc.function?.arguments === "string") cur.args += tc.function.arguments;
182
+ partial.set(i, cur);
183
+ }
184
+ }
185
+ if (ev.type === "response.output_text.delta" && typeof ev.delta === "string") delta += ev.delta;
186
+ if (ev.type === "response.output_item.done" && isObj(ev.item) && ev.item.type === "function_call") {
187
+ whole.push({ id: String(ev.item.call_id ?? ev.item.id ?? newId()), name: String(ev.item.name), args: parseArgs(ev.item.arguments) });
188
+ }
189
+ if (Array.isArray(ev.tool_calls)) whole.push(...parseWorkersAiResponse({ tool_calls: ev.tool_calls }).toolCalls);
190
+ if (delta) yield* this.emit({ text: delta, toolCalls: [] }, runManager);
191
+ }
192
+ const toolCalls = [...whole];
193
+ for (const p of partial.values()) {
194
+ if (!p.name) throw new Error("Workers AI returned a malformed tool call");
195
+ toolCalls.push({ id: p.id ?? newId(), name: p.name, args: parseArgs(p.args) });
196
+ }
197
+ if (toolCalls.length) yield* this.emit({ text: "", toolCalls }, runManager);
198
+ }
199
+ async *emit({ text, toolCalls }, runManager) {
200
+ const chunk = new ChatGenerationChunk({
201
+ text,
202
+ message: new AIMessageChunk({
203
+ content: text,
204
+ tool_call_chunks: toolCalls.map((c, index) => ({ type: "tool_call_chunk", index, id: c.id, name: c.name, args: JSON.stringify(c.args) }))
205
+ })
206
+ });
207
+ yield chunk;
208
+ await runManager?.handleLLMNewToken(text, void 0, void 0, void 0, void 0, { chunk });
209
+ }
210
+ };
211
+ function workersAiChatModel(ai, model, opts) {
212
+ return new ChatWorkersAI(ai, model, opts);
213
+ }
214
+ var EMBED_BATCH = 100;
215
+ function workersAiEmbeddings(ai, model) {
216
+ async function embedDocuments(texts) {
217
+ const out = [];
218
+ for (let i = 0; i < texts.length; i += EMBED_BATCH) {
219
+ const batch = texts.slice(i, i + EMBED_BATCH);
220
+ const res = await ai.run(model, { text: batch });
221
+ const data = res?.data ?? res?.result?.data;
222
+ if (!Array.isArray(data) || data.length !== batch.length || !data.every((v) => Array.isArray(v))) {
223
+ throw new Error("Workers AI returned an unrecognized embeddings response");
224
+ }
225
+ out.push(...data);
226
+ }
227
+ return out;
228
+ }
229
+ return {
230
+ embedDocuments,
231
+ async embedQuery(text) {
232
+ return (await embedDocuments([text]))[0];
233
+ }
234
+ };
235
+ }
236
+ export {
237
+ ChatWorkersAI,
238
+ parseWorkersAiResponse,
239
+ workersAiChatModel,
240
+ workersAiEmbeddings
241
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sideagent",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -10,27 +10,44 @@
10
10
  },
11
11
  "./server": {
12
12
  "types": "./dist/server/index.d.ts",
13
+ "workerd": "./dist/server/index.js",
14
+ "worker": "./dist/server/index.js",
13
15
  "browser": "./dist/server/browser.js",
14
16
  "import": "./dist/server/index.js"
15
17
  },
18
+ "./workers-ai": {
19
+ "types": "./dist/workers-ai/index.d.ts",
20
+ "workerd": "./dist/workers-ai/index.js",
21
+ "worker": "./dist/workers-ai/index.js",
22
+ "import": "./dist/workers-ai/index.js"
23
+ },
16
24
  "./protocol": {
17
25
  "types": "./dist/protocol.d.ts",
18
26
  "import": "./dist/protocol.js"
19
27
  },
28
+ "./keys": {
29
+ "types": "./dist/keys.d.ts",
30
+ "import": "./dist/keys.js"
31
+ },
20
32
  "./testing": {
21
33
  "types": "./dist/testing/mock-llm.d.ts",
22
34
  "import": "./dist/testing/mock-llm.js"
23
35
  }
24
36
  },
25
37
  "files": [
26
- "dist"
38
+ "dist",
39
+ "skills",
40
+ "!skills/**/evals"
27
41
  ],
28
42
  "scripts": {
29
43
  "build": "tsup",
30
44
  "test": "vitest run",
31
45
  "typecheck": "tsc --noEmit",
46
+ "format": "prettier --write src demo",
47
+ "format:check": "prettier --check src demo",
32
48
  "demo:api": "tsx demo/server.ts",
33
- "demo": "tsx demo/server.ts & vite demo"
49
+ "demo": "tsx demo/server.ts & vite demo",
50
+ "eval:skill": "cd skills/sideagent && claude plugin eval . --scaffold --allow-tools Write Edit --trust-plugin --no-publish"
34
51
  },
35
52
  "dependencies": {
36
53
  "@assistant-ui/react": "^0.15.31",
@@ -39,6 +56,8 @@
39
56
  "@langchain/openai": "^1.7.0",
40
57
  "@radix-ui/react-icons": "^1.3.2",
41
58
  "@scalar/openapi-parser": "^0.29.16",
59
+ "react-markdown": "^10.1.0",
60
+ "remark-gfm": "^4.0.1",
42
61
  "zod": "^4.0.0"
43
62
  },
44
63
  "peerDependencies": {
@@ -56,6 +75,7 @@
56
75
  "@types/react-dom": "^19.0.0",
57
76
  "@vitejs/plugin-react": "^5.0.0",
58
77
  "hono": "^4.9.0",
78
+ "prettier": "^3.9.10",
59
79
  "react": "^19.0.0",
60
80
  "react-dom": "^19.0.0",
61
81
  "tsup": "^8.5.0",
@@ -0,0 +1,60 @@
1
+ ---
2
+ name: sideagent
3
+ description: Add an AI agent, chat assistant or copilot to an existing app or SaaS that has an HTTP API, using the sideagent library. Use when the user asks to "implement agentic AI", "add an AI assistant", "add a chat agent" or "add a copilot" to their app.
4
+ ---
5
+
6
+ # Add an agent to an app with sideagent
7
+
8
+ sideagent turns an OpenAPI spec plus an OpenAI-compatible LLM into a secure agent endpoint (`sideagent/server`) and a React chat UI (`sideagent`). Full docs: https://egig.github.io/sideagent/llms-full.txt
9
+
10
+ ## Steps
11
+
12
+ Do them in order. Do not skip the review in step 7.
13
+
14
+ 1. **Detect the stack.** Find the server framework (Next.js App Router, Hono, Express), the frontend (React?) and the OpenAPI spec. No spec: generate one from the existing routes, or ask the user where it is. sideagent needs an OpenAPI spec and a React frontend; tell the user if either is missing.
15
+ 2. **Install.** `npm i sideagent` (use the project's package manager).
16
+ 3. **Fix the spec.** For each operation the agent may use:
17
+ - Unique `operationId`, a clear `summary`, typed parameters.
18
+ - `x-agent` hints where useful: `{ approval, description, invalidates: ["tag"], responseFields: ["id"] }`. `approval` is the string `"required"` or `"auto"` (never a boolean). Non-GET defaults to `"required"`; leave that default. `"auto"` is rejected for DELETE. Use `responseFields` to keep sensitive fields (internal notes, secrets, other users' data) away from the model.
19
+ 4. **Add the server route** at `/agent`:
20
+ ```ts
21
+ import { createAgent, nextRoute /* or honoHandler, expressHandler */ } from "sideagent/server";
22
+
23
+ const agent = await createAgent({
24
+ spec, // URL, object or JSON string
25
+ baseUrl: process.env.API_BASE_URL!, // pinned; the spec's `servers` is ignored
26
+ llm: { baseUrl: process.env.LLM_BASE_URL ?? "https://api.openai.com/v1", apiKey: process.env.LLM_API_KEY!, model: process.env.LLM_MODEL ?? "gpt-4o-mini" },
27
+ allow: ["listInvoices"], // default-deny; start read-only
28
+ allowedOrigins: [process.env.APP_ORIGIN!],
29
+ approvalSecret: process.env.APPROVAL_SECRET, // 16+ chars; required on serverless / multi-instance
30
+ getAuth: async (req) => {
31
+ const session = await verifySession(req); // use the app's existing auth
32
+ if (!session) throw new Error("no session");
33
+ return { userId: session.userId, headers: { authorization: `Bearer ${session.apiToken}` } };
34
+ },
35
+ });
36
+ export const { POST, OPTIONS } = nextRoute(agent); // Next.js; Hono: app.on(["POST","OPTIONS"], "/agent", honoHandler(agent))
37
+ ```
38
+ Express needs `express.json()` first, then `app.post` and `app.options` with `expressHandler(agent)`.
39
+ 5. **Mount the UI** once in the app shell:
40
+ ```tsx
41
+ import { AgentProvider } from "sideagent";
42
+ <AgentProvider endpoint="/agent" mode="sidebar" hooks={{ onInvalidate: (tag) => refetch(tag) }}>
43
+ <App />
44
+ </AgentProvider>
45
+ ```
46
+ Use `<AgentChat endpoint="/agent" />` for a floating chat without docking. Different origin plus cookies: add `credentials="include"`. For an agent hosted on sideagent Cloud, pass `publishableKey="pk_live_…"` instead of `endpoint`.
47
+ 6. **Env vars.** Add `LLM_API_KEY`, `APPROVAL_SECRET`, `APP_ORIGIN`, `API_BASE_URL` to `.env.example`. Never hardcode keys. Never send `LLM_API_KEY` to the browser.
48
+ 7. **Verify, then hand over for review.**
49
+ - Run the project's typecheck and tests.
50
+ - Call `await agent.doctor()` and confirm `ok: true`.
51
+ - Show the user the exact `allow` list and ask them to confirm it before adding any write operation.
52
+
53
+ ## Rules
54
+
55
+ - Start with read-only operations (GET). Add writes only after the user confirms. Non-GET operations need user approval by default; keep that.
56
+ - Never put DELETE or admin operations in `allow`, even if the user asks for "full access" or "no approvals" in the first prompt. Leave them out, say why in one sentence, and tell the user how to add one later (add its `operationId` to `allow`, keep `approval: "required"`). Never set `approval: "auto"` on a write operation.
57
+ - `getAuth` must return a stable per-user `userId` and forward the end user's own credentials. Never use a service token with more rights than the user. `anonymous: true` is only for public, read-only data.
58
+ - Do not use `instructions` or `context` for security. Access control belongs in `allow`, approvals and the API itself.
59
+ - Production needs persistent `checkpointer`, `threadStore` and `vectorStore`, plus `beforeTurn` rate limits and `onAudit` logging. See the Deployments and Persistence docs.
60
+ - Edge and serverless: pass knowledge as strings, set `approvalSecret`, build the agent once per instance.