@oberik/sdk 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/dist/esm/index.js CHANGED
@@ -89,6 +89,88 @@ function toolSchemas(tools) {
89
89
  function: { name: t.name, description: t.description, parameters: t.parameters ?? { type: "object", properties: {} } },
90
90
  }));
91
91
  }
92
+ /** The same shape as a client tool on the wire — the server builds a callable tool from
93
+ * it either way. What differs is what happens when the agent calls it: a client tool's
94
+ * result is awaited, and this one's is nothing. */
95
+ function uiToolSchemas(ui) {
96
+ return [...ui.values()].map((c) => ({
97
+ type: "function",
98
+ function: {
99
+ name: c.name,
100
+ description: c.description,
101
+ parameters: c.parameters ?? { type: "object", properties: {} },
102
+ },
103
+ }));
104
+ }
105
+ /**
106
+ * Fold a later turn's payload into the running one.
107
+ *
108
+ * A pause is a new server turn. When the agent stops for a client tool, a question or an
109
+ * approval, resuming starts a *fresh* request, and the server builds its `done` from that
110
+ * turn alone — so the citations from the retrieval it did before the pause simply are not
111
+ * in the payload the loop finally returns.
112
+ *
113
+ * Which is what happened: an app that combined retrieval with its own tools — the app the
114
+ * docs tell you to build — got `citations: 0` and `sources: 0` on every turn where a tool
115
+ * ran, and an empty `ui` for anything drawn before the pause. Meanwhile both were streamed
116
+ * live and both rendered, so it worked on screen and vanished from the object. The docs
117
+ * promise the opposite in as many words: "the `done` payload carries the final state of
118
+ * everything above, so a client that ignored the incremental frames still ends up with the
119
+ * whole turn."
120
+ *
121
+ * Three kinds of field, and getting the kind wrong is its own bug:
122
+ * - accumulated: prose and lists the turn produced across all its segments
123
+ * - deduped: retrieval, which is very likely to repeat across segments
124
+ * - last-wins: what the *server* recomputes each time and is authoritative about
125
+ */
126
+ function mergeDone(prev, next) {
127
+ if (!prev)
128
+ return next;
129
+ const byKey = (items, key) => {
130
+ const seen = new Set();
131
+ const out = [];
132
+ for (const it of items) {
133
+ const k = key(it);
134
+ if (seen.has(k))
135
+ continue;
136
+ seen.add(k);
137
+ out.push(it);
138
+ }
139
+ return out;
140
+ };
141
+ return {
142
+ ...next,
143
+ // Accumulated: everything the agent said and drew, in order.
144
+ content: [prev.content, next.content].filter(Boolean).join(""),
145
+ // Claims accumulate, and the later segment's offsets shift by however much prose came
146
+ // before it. Getting this wrong points a footnote at the wrong sentence, which is worse
147
+ // than having no footnote.
148
+ claims: [
149
+ ...(prev.claims ?? []),
150
+ ...(next.claims ?? []).map((claim) => ({
151
+ ...claim,
152
+ start: claim.start == null ? null : claim.start + prev.content.length,
153
+ end: claim.end == null ? null : claim.end + prev.content.length,
154
+ })),
155
+ ],
156
+ attribution: prev.attribution === "per-claim" || next.attribution === "per-claim"
157
+ ? "per-claim"
158
+ : prev.attribution === "retrieval-only" || next.attribution === "retrieval-only"
159
+ ? "retrieval-only"
160
+ : "none",
161
+ reasoning: [prev.reasoning ?? "", next.reasoning ?? ""].filter(Boolean).join("") || undefined,
162
+ ui: [...(prev.ui ?? []), ...(next.ui ?? [])],
163
+ guard_flags: [...new Set([...(prev.guard_flags ?? []), ...(next.guard_flags ?? [])])],
164
+ // Deduped: the same chunk retrieved twice is one citation, and an attachment carried
165
+ // forward across a pause is one file.
166
+ citations: byKey([...(prev.citations ?? []), ...(next.citations ?? [])], (c) => `${c.document_id}:${c.chunk_index}`),
167
+ sources: byKey([...(prev.sources ?? []), ...(next.sources ?? [])], (s) => String(s.url ?? `${s.document_id}:${s.chunk_index ?? ""}`)),
168
+ attachments: byKey([...(prev.attachments ?? []), ...(next.attachments ?? [])], (a) => String(a.id ?? a.s3_key ?? a.filename)),
169
+ // Last-wins: the server recomputes these per turn and is right about them. `todos` is
170
+ // the whole current plan, not a delta; `subagents` is every subagent of the
171
+ // conversation; `context` describes the window as it is now.
172
+ };
173
+ }
92
174
  /** Run each pending client tool via its handler; failures become error results
93
175
  * (not thrown) so one bad tool doesn't abort the whole turn. */
94
176
  async function executeToolCalls(calls, tools) {
@@ -110,6 +192,26 @@ async function executeToolCalls(calls, tools) {
110
192
  return { tool_call_id: call.id, content };
111
193
  }));
112
194
  }
195
+ /** Put each approval request to the handler and shape the result for the API.
196
+ *
197
+ * A handler that throws is treated as a refusal rather than as a failure. That is the
198
+ * safe direction and the only defensible one: the alternative is a UI bug becoming an
199
+ * irreversible action. */
200
+ async function collectDecisions(pending, handler) {
201
+ const out = [];
202
+ for (const request of pending) {
203
+ let decision;
204
+ try {
205
+ const result = await handler(request);
206
+ decision = typeof result === "boolean" ? { approved: result } : result;
207
+ }
208
+ catch (e) {
209
+ decision = { approved: false, note: String(e?.message ?? e) };
210
+ }
211
+ out.push({ ...decision, tool_call_id: decision.tool_call_id ?? request.tool_call_id });
212
+ }
213
+ return out;
214
+ }
113
215
  /** Put each paused question batch to the handler and shape the result for the API.
114
216
  *
115
217
  * A handler that throws is treated as the user declining rather than as a failure:
@@ -192,6 +294,7 @@ export class AgentFramework {
192
294
  opts;
193
295
  _fetch;
194
296
  toolRegistry = new Map();
297
+ uiRegistry = new Map();
195
298
  /** Active session watchers, so a streamed turn can mark its own messages seen. */
196
299
  watchers = new Set();
197
300
  /** The bearer in use: `opts.token` initially, replaced on refresh. */
@@ -213,6 +316,8 @@ export class AgentFramework {
213
316
  this.currentToken = opts.token;
214
317
  for (const t of opts.tools ?? [])
215
318
  this.toolRegistry.set(t.name, t);
319
+ for (const c of opts.ui ?? [])
320
+ this.uiRegistry.set(c.name, c);
216
321
  }
217
322
  /** Seconds before a token's own expiry at which we stop using it.
218
323
  *
@@ -688,6 +793,19 @@ export class AgentFramework {
688
793
  this.toolRegistry.set(t.name, t);
689
794
  return this;
690
795
  }
796
+ /** Register a UI component the agent can draw into your app.
797
+ *
798
+ * Unlike a tool, nothing is handed back: the agent calls it, your `render` runs, and
799
+ * the turn carries on without waiting. */
800
+ registerUi(component) {
801
+ this.uiRegistry.set(component.name, component);
802
+ return this;
803
+ }
804
+ registerUiComponents(components) {
805
+ for (const c of components)
806
+ this.uiRegistry.set(c.name, c);
807
+ return this;
808
+ }
691
809
  /** Merge the client-level registry with any per-call tools (per-call wins). */
692
810
  resolveTools(extra) {
693
811
  const m = new Map(this.toolRegistry);
@@ -695,6 +813,29 @@ export class AgentFramework {
695
813
  m.set(t.name, t);
696
814
  return m;
697
815
  }
816
+ resolveUi(extra) {
817
+ const m = new Map(this.uiRegistry);
818
+ for (const c of extra ?? [])
819
+ m.set(c.name, c);
820
+ return m;
821
+ }
822
+ /** Draw whatever the agent asked for, in order.
823
+ *
824
+ * A component that throws is logged and skipped: one broken chart must not take down
825
+ * the turn that drew it, and there is nothing to report back to the agent anyway. */
826
+ renderUi(renders, registry) {
827
+ for (const r of renders ?? []) {
828
+ const component = registry.get(r.name);
829
+ if (!component)
830
+ continue;
831
+ try {
832
+ component.render(r.args ?? {});
833
+ }
834
+ catch (e) {
835
+ console.warn(`[oberik] UI component "${r.name}" threw while rendering:`, e);
836
+ }
837
+ }
838
+ }
698
839
  // -- auth headers --------------------------------------------------------
699
840
  /** `bearer` overrides token resolution — used to replay a request with the token a
700
841
  * refresh just produced, instead of asking for one again. */
@@ -999,6 +1140,50 @@ export class AgentFramework {
999
1140
  },
1000
1141
  };
1001
1142
  // -- documents -----------------------------------------------------------
1143
+ /** Agent Plugins — the skills this token can reach for, and the ones it may add.
1144
+ *
1145
+ * Two sources, one list: what the project published plus anything this end-user
1146
+ * uploaded. Uploading needs the `plugins:write` capability; reading does not, because
1147
+ * a project that publishes a procedure wants its agent to use it. */
1148
+ plugins = {
1149
+ list: () => this.request("GET", "/plugins"),
1150
+ delete: (id) => this.request("DELETE", `/plugins/${id}`),
1151
+ /** Publish a skill. Re-uploading a name replaces it.
1152
+ *
1153
+ * Takes whatever the customer actually has:
1154
+ *
1155
+ * - a packaged Agent Plugin (`plugin.json` + `skills/`), read as-is;
1156
+ * - a zipped folder of skills, or a single `SKILL.md` — a manifest is written
1157
+ * for them, because requiring one to publish a file of instructions is a
1158
+ * packaging exercise standing in front of the feature;
1159
+ * - a folder's files, from a directory picker, each keyed by its relative path.
1160
+ *
1161
+ * An end-user's plugin is private to them and unioned on top of the project's —
1162
+ * only a project key can publish to everyone. */
1163
+ upload: async (file, opts = {}) => {
1164
+ const form = new FormData();
1165
+ const blob = typeof Blob !== "undefined" && file instanceof Blob
1166
+ ? file
1167
+ : new Blob([file], { type: "application/zip" });
1168
+ form.append("file", blob, opts.filename ?? file?.name ?? "plugin.zip");
1169
+ return this.request("POST", "/plugins", { form, signal: opts.signal });
1170
+ },
1171
+ /** Publish a folder of skills without zipping it.
1172
+ *
1173
+ * `files` is what a browser directory picker gives you. Each part is sent under
1174
+ * its path relative to the folder, which is all the server needs to lay the
1175
+ * skills out — so no zip library is needed on your side. */
1176
+ uploadFolder: async (files, opts = {}) => {
1177
+ const form = new FormData();
1178
+ for (const { path, content } of files) {
1179
+ const blob = typeof Blob !== "undefined" && content instanceof Blob
1180
+ ? content
1181
+ : new Blob([content]);
1182
+ form.append("files", blob, path);
1183
+ }
1184
+ return this.request("POST", "/plugins", { form, signal: opts.signal });
1185
+ },
1186
+ };
1002
1187
  documents = {
1003
1188
  list: (query = {}) => this.request("GET", "/documents", { query }),
1004
1189
  get: (id) => this.request("GET", `/documents/${id}`),
@@ -1089,20 +1274,61 @@ export class AgentFramework {
1089
1274
  get: (id) => this.request("GET", `/tasks/${id}`),
1090
1275
  cancel: (id) => this.request("POST", `/tasks/${id}/cancel`),
1091
1276
  };
1092
- // -- knowledge sources (ai.sync data connectors) -------------------------
1093
- sources = {
1094
- list: () => this.request("GET", "/sources"),
1095
- get: (id) => this.request("GET", `/sources/${id}`),
1096
- types: () => this.request("GET", "/sources/types"),
1097
- /** Rotate credentials, retag, change the refresh cadence or the ACL. A new
1098
- * `config` is tested before it replaces the old one, so a bad DSN fails here
1099
- * (400) instead of at the next scheduled sync. Changing the interval
1100
- * reschedules; 0 turns auto-refresh off. */
1101
- patch: (id, body) => this.request("PATCH", `/sources/${id}`, { body }),
1102
- /** Trigger a sync now; pass `fullRefresh` to re-pull everything. */
1103
- sync: (id, fullRefresh = false) => this.request("POST", `/sources/${id}/sync`, { query: { full_refresh: fullRefresh } }),
1104
- remove: (id) => this.request("DELETE", `/sources/${id}`),
1277
+ /**
1278
+ * Turns that start because something happened somewhere else.
1279
+ *
1280
+ * A trigger is a URL you give another system — a ticket tracker, a CI job, a Zapier step
1281
+ * and the path is the credential, so it runs as a fixed subject chosen at creation.
1282
+ * These were the only routes in the API reference with no SDK method: every integration
1283
+ * hand-wrote `fetch` for them.
1284
+ *
1285
+ * The URL comes back absolute and is not a secret we can show once: the whole point is
1286
+ * that someone else's configuration holds it. `secret` IS shown once — with it, the
1287
+ * sender signs the body and the URL stops being a bearer token.
1288
+ */
1289
+ triggers = {
1290
+ list: () => this.request("GET", "/triggers"),
1291
+ /** `prompt` may interpolate the event: `"A ticket arrived: {{ body.title }}"`. */
1292
+ create: (opts) => this.request("POST", "/triggers", {
1293
+ body: {
1294
+ prompt: opts.prompt,
1295
+ name: opts.name,
1296
+ system_prompt: opts.systemPrompt,
1297
+ session_id: opts.sessionId,
1298
+ signed: opts.signed ?? false,
1299
+ },
1300
+ }),
1301
+ delete: (triggerId) => this.request("DELETE", `/triggers/${triggerId}`),
1302
+ /** A new URL, with the old one alive for 24 hours — so telling the other system its new
1303
+ * address is not an outage. */
1304
+ rotate: (triggerId) => this.request("POST", `/triggers/${triggerId}/rotate`),
1305
+ };
1306
+ /**
1307
+ * What the agent wrote down — remembered facts and wiki pages.
1308
+ *
1309
+ * Retrieval reads these back on every turn, so when an answer looks wrong a
1310
+ * remembered fact is often the reason. The ACL that governs retrieval governs this
1311
+ * too: a caller sees exactly what its token could have retrieved.
1312
+ */
1313
+ memory = {
1314
+ /** `kind` is "memory" (facts, per end-user) or "wiki" (pages, per user or shared). */
1315
+ list: (opts = {}) => this.request("GET", "/memory", { query: { kind: opts.kind ?? "memory", limit: opts.limit } }),
1316
+ /** Forget one. The agent can write it again; this removes what is there now. */
1317
+ delete: (itemId) => this.request("DELETE", `/memory/${itemId}`),
1105
1318
  };
1319
+ /**
1320
+ * What this token can actually do.
1321
+ *
1322
+ * The three gates on a turn are the platform's kill switch, the token's capability
1323
+ * and the per-request `enable_*` flag, and until this existed a client could read
1324
+ * none of them. `flags[].effective` is the useful one: false means setting that flag
1325
+ * changes nothing on this token — which is otherwise indistinguishable from the agent
1326
+ * simply choosing not to use the tool.
1327
+ *
1328
+ * Cheap and safe to call on load: a token asking what it holds is reading its own
1329
+ * claims back, so it needs no capability of its own.
1330
+ */
1331
+ capabilities = () => this.request("GET", "/capabilities");
1106
1332
  // -- governance: audit trail + right-to-be-forgotten (admin) -------------
1107
1333
  audit = {
1108
1334
  /** Read the tenant's audit trail (admin). Filter by action/subject. */
@@ -1155,81 +1381,93 @@ export class AgentFramework {
1155
1381
  return res.blob();
1156
1382
  },
1157
1383
  };
1158
- /**
1159
- * Connect an external data source and keep it live in the agent's knowledge.
1160
- * Validates the connector, runs an initial sync, and (when an interval is set)
1161
- * schedules recurring auto-refresh so the data never goes stale.
1162
- *
1163
- * await ai.sync({ name: "orders", connector_type: "postgres",
1164
- * config: { dsn, query: "select id, status, total from orders",
1165
- * cursor_column: "updated_at" }, sync_interval_seconds: 900 });
1166
- */
1167
- sync = (config) => this.request("POST", "/sources", { body: config });
1168
1384
  // ==========================================================================
1169
1385
  // Automatic client-tool dispatch
1170
1386
  // ==========================================================================
1171
1387
  async runWithTools(opts) {
1172
- const { tools: extra, maxToolRounds, onToolCalls, onQuestion, ...body } = opts;
1388
+ const { tools: extra, ui: extraUi, maxToolRounds, onToolCalls, onQuestion, onApproval, ...body } = opts;
1173
1389
  const tools = this.resolveTools(extra);
1390
+ const ui = this.resolveUi(extraUi);
1174
1391
  const schemas = tools.size ? toolSchemas(tools) : undefined;
1392
+ const uiSchemas = ui.size ? uiToolSchemas(ui) : undefined;
1175
1393
  const maxRounds = maxToolRounds ?? 10;
1176
1394
  let resp = await this.request("POST", "/chat", {
1177
- body: { ...body, client_tools: schemas },
1395
+ body: { ...body, client_tools: schemas, ui_tools: uiSchemas },
1178
1396
  signal: opts.signal,
1179
1397
  });
1398
+ this.renderUi(resp.ui, ui);
1399
+ // What the caller gets back: every segment folded together. Each pause starts a new
1400
+ // server turn, so the last segment's payload knows nothing about the retrieval that
1401
+ // happened before it.
1402
+ let merged = resp;
1180
1403
  let rounds = 0;
1181
- // Two ways a turn pauses: work for the client to run, or a question for the user
1182
- // to answer. Both resume the same way, and the round budget covers both together
1183
- // so a pathological alternation can't loop forever.
1404
+ // Three ways a turn pauses: work for the client to run, a question for the user to
1405
+ // answer, or permission to ask for. All resume the same way, and the round budget
1406
+ // covers them together so a pathological alternation can't loop forever.
1184
1407
  for (;;) {
1185
1408
  const hasTools = resp.requires_action && resp.tool_calls.length > 0;
1186
1409
  const hasQuestions = onQuestion != null && (resp.questions?.length ?? 0) > 0;
1187
- if (!hasTools && !hasQuestions)
1188
- return resp;
1410
+ const hasApprovals = onApproval != null && (resp.approvals?.length ?? 0) > 0;
1411
+ if (!hasTools && !hasQuestions && !hasApprovals)
1412
+ return merged;
1189
1413
  if (rounds++ >= maxRounds)
1190
1414
  throw new AgentStreamError(`agent pause loop exceeded maxToolRounds (${maxRounds})`);
1191
- const next = { session_id: resp.session_id, client_tools: schemas };
1415
+ const next = {
1416
+ session_id: resp.session_id, client_tools: schemas, ui_tools: uiSchemas,
1417
+ };
1192
1418
  if (hasTools) {
1193
1419
  onToolCalls?.(resp.tool_calls);
1194
1420
  next.tool_results = await executeToolCalls(resp.tool_calls, tools);
1195
1421
  }
1196
1422
  if (hasQuestions)
1197
1423
  next.question_answers = await collectAnswers(resp.questions, onQuestion);
1424
+ if (hasApprovals)
1425
+ next.approval_decisions = await collectDecisions(resp.approvals, onApproval);
1198
1426
  resp = await this.request("POST", "/chat", { body: next, signal: opts.signal });
1427
+ this.renderUi(resp.ui, ui);
1428
+ merged = mergeDone(merged, resp);
1199
1429
  }
1200
1430
  }
1201
1431
  /** Wrap startStream so a `done` carrying `requires_action` auto-executes the
1202
1432
  * client tools and continues the stream (same session) until the agent ends. */
1203
1433
  startStreamWithTools(body, handlers) {
1204
1434
  const tools = this.resolveTools(handlers.tools);
1435
+ const ui = this.resolveUi(handlers.ui);
1205
1436
  const onQuestion = handlers.onQuestion;
1206
- if (tools.size === 0 && onQuestion == null)
1437
+ const onApproval = handlers.onApproval;
1438
+ if (tools.size === 0 && ui.size === 0 && onQuestion == null && onApproval == null)
1207
1439
  return this.startStream(body, handlers);
1208
1440
  const schemas = tools.size ? toolSchemas(tools) : undefined;
1209
1441
  const maxRounds = handlers.maxToolRounds ?? 10;
1210
1442
  let current;
1211
1443
  let stopped = false;
1212
1444
  const done = (async () => {
1213
- let reqBody = { ...body, client_tools: schemas };
1445
+ const uiSchemas = ui.size ? uiToolSchemas(ui) : undefined;
1446
+ let reqBody = { ...body, client_tools: schemas, ui_tools: uiSchemas };
1447
+ let merged;
1214
1448
  let rounds = 0;
1215
1449
  for (;;) {
1216
1450
  current = this.startStream(reqBody, handlers);
1217
1451
  const d = await current.done;
1452
+ merged = mergeDone(merged, d);
1218
1453
  const hasTools = d.requires_action && d.tool_calls.length > 0;
1219
1454
  const hasQuestions = onQuestion != null && (d.questions?.length ?? 0) > 0;
1220
- if (!hasTools && !hasQuestions)
1221
- return d;
1455
+ const hasApprovals = onApproval != null && (d.approvals?.length ?? 0) > 0;
1456
+ if (!hasTools && !hasQuestions && !hasApprovals)
1457
+ return merged;
1222
1458
  if (stopped)
1223
- return d;
1459
+ return merged;
1224
1460
  if (rounds++ >= maxRounds)
1225
1461
  throw new AgentStreamError(`agent pause loop exceeded maxToolRounds (${maxRounds})`);
1226
- reqBody = { session_id: d.session_id, client_tools: schemas };
1462
+ reqBody = { session_id: d.session_id, client_tools: schemas, ui_tools: uiSchemas };
1227
1463
  if (hasTools) {
1228
1464
  handlers.onToolCalls?.(d.tool_calls);
1229
1465
  reqBody.tool_results = await executeToolCalls(d.tool_calls, tools);
1230
1466
  }
1231
1467
  if (hasQuestions)
1232
1468
  reqBody.question_answers = await collectAnswers(d.questions, onQuestion);
1469
+ if (hasApprovals)
1470
+ reqBody.approval_decisions = await collectDecisions(d.approvals, onApproval);
1233
1471
  }
1234
1472
  })();
1235
1473
  return makeStreamHandle(done, {
@@ -1366,6 +1604,9 @@ export class AgentFramework {
1366
1604
  const onOuterAbort = () => ac.abort();
1367
1605
  handlers.signal?.addEventListener("abort", onOuterAbort);
1368
1606
  const maxRetries = handlers.maxRetries ?? 10;
1607
+ // The components this stream may draw. Resolved once: the registry can be added to
1608
+ // between turns, and a stream should draw with what it was started with.
1609
+ const uiRegistry = handlers.ui || this.uiRegistry.size ? this.resolveUi(handlers.ui) : undefined;
1369
1610
  let runId;
1370
1611
  let lastId = -1;
1371
1612
  let full = "";
@@ -1451,6 +1692,12 @@ export class AgentFramework {
1451
1692
  case "todos":
1452
1693
  handlers.onTodos?.(ev.data.todos);
1453
1694
  break;
1695
+ case "ui":
1696
+ // Drawn the moment the agent calls it, mid-turn — waiting for `done`
1697
+ // would mean a chart appearing after the paragraph that refers to it.
1698
+ uiRegistry?.get(ev.data.name)?.render(ev.data.args ?? {});
1699
+ handlers.onUi?.(ev.data);
1700
+ break;
1454
1701
  case "subagent":
1455
1702
  subagents.set(ev.data.subagent.ref, ev.data.subagent);
1456
1703
  handlers.onSubagents?.([...subagents.values()]);
@@ -1735,6 +1982,277 @@ function safeJson(s) {
1735
1982
  return s;
1736
1983
  }
1737
1984
  }
1985
+ // ============================================================================
1986
+ // The server side: a project key, and everything it can do
1987
+ // ============================================================================
1988
+ /** Where projects are administered and end-user tokens are minted. A different host
1989
+ * from {@link DEFAULT_BASE_URL}, and a different credential — the two are not
1990
+ * interchangeable, which is the whole reason there are two clients. */
1991
+ export const DEFAULT_CONTROL_PLANE_URL = "https://oberik.com";
1992
+ /**
1993
+ * The control plane, from your backend.
1994
+ *
1995
+ * The other client in this package talks to the data plane as one end-user. This one
1996
+ * holds the project key and administers the project itself: minting those tokens,
1997
+ * setting the capability ceiling, curating the corpus.
1998
+ *
1999
+ * They are separate classes on purpose. A project key can mint a token with any
2000
+ * capability the project allows — and create further keys, and delete the project — so
2001
+ * it must never travel to the same place an end-user token does. Two types make that a
2002
+ * decision someone has to make rather than a field they can accidentally set.
2003
+ */
2004
+ export class OberikProject {
2005
+ baseUrl;
2006
+ projectId;
2007
+ key;
2008
+ _fetch;
2009
+ constructor(opts) {
2010
+ if (!opts.projectId)
2011
+ throw new Error("projectId is required");
2012
+ if (!opts.projectKey)
2013
+ throw new Error("projectKey is required");
2014
+ // A project key in a browser is never right: it mints any capability the project
2015
+ // allows and can delete the project outright. Bundling this by accident is easy —
2016
+ // it is one import away from the client that DOES belong there — so it fails at
2017
+ // construction, where the stack trace names the file that did it, rather than
2018
+ // shipping and leaking the key to every visitor.
2019
+ if (typeof window !== "undefined" && typeof window.document !== "undefined") {
2020
+ throw new Error("OberikProject holds a project key and must never run in a browser — it can mint " +
2021
+ "any capability the project allows, create more keys, and delete the project. " +
2022
+ "Mint tokens on your server and send the token to the browser instead: " +
2023
+ "createClient({ getToken }).");
2024
+ }
2025
+ this.projectId = opts.projectId;
2026
+ this.key = opts.projectKey;
2027
+ this.baseUrl = (opts.baseUrl || DEFAULT_CONTROL_PLANE_URL).replace(/\/+$/, "");
2028
+ const raw = opts.fetch ?? globalThis.fetch;
2029
+ if (!raw)
2030
+ throw new Error("No fetch available; pass options.fetch");
2031
+ this._fetch = raw.bind(globalThis);
2032
+ }
2033
+ async request(method, path, body, form) {
2034
+ const res = await this._fetch(`${this.baseUrl}/api/projects/${this.projectId}${path}`, {
2035
+ method,
2036
+ headers: {
2037
+ "X-API-Key": this.key,
2038
+ ...(body !== undefined ? { "content-type": "application/json" } : {}),
2039
+ },
2040
+ body: form ?? (body !== undefined ? JSON.stringify(body) : undefined),
2041
+ });
2042
+ const text = await res.text();
2043
+ const data = text ? safeJson(text) : null;
2044
+ if (!res.ok) {
2045
+ throw new AgentApiError(res.status, (data && (data.error || data.message)) || `HTTP ${res.status}`);
2046
+ }
2047
+ return data;
2048
+ }
2049
+ tokens = {
2050
+ /**
2051
+ * Mint a short-lived token for ONE end-user.
2052
+ *
2053
+ * const { access_token } = await oberik.tokens.mint({
2054
+ * subject: `${user.orgId}:${user.id}`,
2055
+ * scope: `${user.orgId}:${user.id}`,
2056
+ * capabilities: ["chat", "documents:read"],
2057
+ * });
2058
+ */
2059
+ mint: (input) => this.request("POST", "/token", input),
2060
+ /**
2061
+ * The same thing shaped as the callback {@link createClient} wants, so the two
2062
+ * halves of this package fit together without a wrapper:
2063
+ *
2064
+ * const ai = createClient({ getToken: oberik.tokens.forUser({ subject: id }) });
2065
+ *
2066
+ * Called again whenever a token expires, so the client refreshes on its own.
2067
+ */
2068
+ forUser: (input) => async () => (await this.request("POST", "/token", input)).access_token,
2069
+ };
2070
+ /** The capability ceiling: the maximum any token minted here may hold. */
2071
+ capabilities = {
2072
+ get: () => this.request("GET", "/capabilities"),
2073
+ /** Merges — send only what you want to change. */
2074
+ set: (caps) => this.request("PATCH", "", { capabilities: caps }),
2075
+ };
2076
+ /** Documents owned by the project rather than by any one end-user: the corpus you
2077
+ * curate and your users only read. */
2078
+ /**
2079
+ * The corpus you curate.
2080
+ *
2081
+ * Uploads here are stored `tenant`-visible — readable by every end-user of the project —
2082
+ * which is what "a corpus you curate, that users only read" means. That is the default
2083
+ * and the only option, deliberately: a project key is not a person, so there is no
2084
+ * per-user subtree for it to write into. Per-user documents go through the data-plane
2085
+ * client with an end-user token, where the subject IS the owner.
2086
+ *
2087
+ * The docs used to show `visibility: "tenant"` being passed here, which was neither
2088
+ * accepted nor needed — a recipe that worked by luck rather than by expression.
2089
+ */
2090
+ documents = {
2091
+ list: () => this.request("GET", "/documents"),
2092
+ upload: (file, opts = {}) => {
2093
+ const form = new FormData();
2094
+ form.append("file", file, opts.filename ?? file.name ?? "upload.bin");
2095
+ form.append("tags", (opts.tags ?? []).join(","));
2096
+ return this.request("POST", "/documents", undefined, form);
2097
+ },
2098
+ delete: (documentId) => this.request("DELETE", `/documents/${documentId}`),
2099
+ };
2100
+ /**
2101
+ * The models this project runs on, and the retrieval it uses.
2102
+ *
2103
+ * These had no methods at all: `project-api.md` documents them in a table and every
2104
+ * integration hand-rolled `fetch` for half its setup. `providers.add` is the one that
2105
+ * matters most — it is the step a new project cannot answer a question without, and it
2106
+ * finishes the rest of the setup itself (see `derived` in the response).
2107
+ *
2108
+ * `providers.catalog` is deliberately absent: it is not project-scoped, so it does not
2109
+ * belong on a client whose every path hangs off one project.
2110
+ */
2111
+ providers = {
2112
+ list: () => this.request("GET", "/providers"),
2113
+ /** Name a chat model AND an embedding model: the first lets the agent answer, the
2114
+ * second lets it index. The response's `derived` says what was set for you. */
2115
+ add: (opts) => this.request("POST", "/providers", opts),
2116
+ edit: (credId, opts) => this.request("PATCH", `/providers/${credId}`, opts),
2117
+ /** Re-read the provider's catalog: a model registered before its price was published
2118
+ * bills nothing, so the usage cap never trips. */
2119
+ refresh: (credId) => this.request("POST", `/providers/${credId}/refresh`, {}),
2120
+ remove: (credId) => this.request("DELETE", `/providers/${credId}`),
2121
+ };
2122
+ /** The model used when a request does not name one. */
2123
+ defaultModel = {
2124
+ set: (model) => this.request("POST", "/default-model", { model }),
2125
+ };
2126
+ /** Embedding and rerank overrides. Set for you when you register an embedding model, so
2127
+ * this is for changing it rather than for getting started. */
2128
+ retrieval = {
2129
+ set: (opts) => this.request("PUT", "/retrieval", opts),
2130
+ /** How many floats a model returns, measured by embedding one word. No provider
2131
+ * publishes it, and a wrong one fails at the first ingest rather than here. */
2132
+ probe: (model) => this.request("POST", "/retrieval/probe", { model }),
2133
+ };
2134
+ /** How documents are read: the built-in parser, or a vision model you choose. */
2135
+ documentProcessor = {
2136
+ set: (opts) => this.request("PUT", "/document-processor", opts),
2137
+ };
2138
+ /** What happens when a conversation outgrows the model's window. */
2139
+ context = {
2140
+ get: () => this.request("GET", "/context"),
2141
+ set: (opts) => this.request("PUT", "/context", opts),
2142
+ };
2143
+ /** Spend and rate caps on this project's LLM key. Enforced by the biller, so they hold
2144
+ * even when the usage views cannot be read. */
2145
+ limits = {
2146
+ get: () => this.request("GET", "/limits"),
2147
+ set: (opts) => this.request("PUT", "/limits", opts),
2148
+ };
2149
+ /** Which models a delegate may run on, and how many may run at once. Without this the
2150
+ * subagents capability stays unavailable however it is granted. */
2151
+ subagents = {
2152
+ set: (opts) => this.request("PUT", "/subagents", opts),
2153
+ };
2154
+ /** Procedures you publish as Agent Plugins, and what your end-users have added. */
2155
+ skills = {
2156
+ list: () => this.request("GET", "/skills"),
2157
+ upload: (zip, filename) => {
2158
+ const form = new FormData();
2159
+ form.append("file", zip, filename ?? zip.name ?? "skill.zip");
2160
+ return this.request("POST", "/skills", undefined, form);
2161
+ },
2162
+ delete: (pluginId) => this.request("DELETE", `/skills/${pluginId}`),
2163
+ };
2164
+ /** MCP servers whose tools join this project's catalog. */
2165
+ mcp = {
2166
+ list: () => this.request("GET", "/mcp"),
2167
+ add: (opts) => this.request("POST", "/mcp", opts),
2168
+ remove: (mcpId) => this.request("DELETE", `/mcp/${mcpId}`),
2169
+ };
2170
+ /** Conversations, and what was said in them. */
2171
+ sessions = {
2172
+ list: () => this.request("GET", "/sessions"),
2173
+ messages: (sessionId) => this.request("GET", `/sessions/${sessionId}/messages`),
2174
+ };
2175
+ /** Scheduled work this project's end-users have created. */
2176
+ tasks = {
2177
+ list: () => this.request("GET", "/tasks"),
2178
+ };
2179
+ /** What the agent has written down: remembered facts and wiki pages. */
2180
+ wiki = {
2181
+ list: () => this.request("GET", "/wiki"),
2182
+ delete: (itemId) => this.request("DELETE", `/wiki/${itemId}`),
2183
+ };
2184
+ /** Live sandboxes, and what to do about one. */
2185
+ sandboxes = {
2186
+ list: () => this.request("GET", "/sandboxes"),
2187
+ action: (sessionId, action) => this.request("POST", `/sandboxes/${sessionId}/${action}`, {}),
2188
+ };
2189
+ /** Prepended to every request for this project, above anything a caller sends. */
2190
+ systemPrompt = {
2191
+ set: (systemPrompt) => this.request("PUT", "/system-prompt", { systemPrompt }),
2192
+ };
2193
+ /** Browser origins allowed to call the data plane with this project's tokens. */
2194
+ origins = {
2195
+ set: (origins) => this.request("PUT", "/origins", { origins }),
2196
+ };
2197
+ /** Tools the agent calls by URL. The signing secret comes back once, on create. */
2198
+ webhookTools = {
2199
+ list: () => this.request("GET", "/webhook-tools"),
2200
+ create: (tool) => this.request("POST", "/webhook-tools", tool),
2201
+ delete: (toolId) => this.request("DELETE", `/webhook-tools/${toolId}`),
2202
+ };
2203
+ /** Server-side keys. A created one is returned once and never again. */
2204
+ /**
2205
+ * Checks on what goes into the model and what comes back.
2206
+ *
2207
+ * The enforcement has existed for a long time and there was no way to configure it — no
2208
+ * route, no dashboard section, no column — so a documentation page described switches
2209
+ * that could not be reached. `set` takes a partial: what you do not mention is left as it
2210
+ * is.
2211
+ */
2212
+ guardrails = {
2213
+ get: () => this.request("GET", "/guardrails"),
2214
+ set: (policy) => this.request("PUT", "/guardrails", { body: policy }),
2215
+ };
2216
+ /**
2217
+ * Whether this project can actually answer a question yet.
2218
+ *
2219
+ * A new project has no models, so it can neither answer nor index anything — and the
2220
+ * flag that used to be the closest thing to this (`hasLlm`) meant "a LiteLLM key was
2221
+ * provisioned", which is true from the moment a project exists. Every unfinished step
2222
+ * names what it blocks and the one call that fixes it.
2223
+ *
2224
+ * Worth calling in a deploy check: a project that is not ready fails every request with
2225
+ * the provider's own error, which reads as your bug rather than as missing setup.
2226
+ */
2227
+ readiness = () => this.request("GET", "/readiness");
2228
+ /** The starting snippet and this project's endpoints — the same one the dashboard and the
2229
+ * SSH gateway show, so there is one of it rather than three. */
2230
+ connect = () => this.request("GET", "/connect");
2231
+ /**
2232
+ * Further project keys.
2233
+ *
2234
+ * `scope` is the important argument and it defaults to the narrow one. A `mint` key
2235
+ * can turn your signed-in user into an end-user token and nothing else — it cannot
2236
+ * read the corpus, raise the capability ceiling, issue more keys, or delete the
2237
+ * project. That is what almost every backend actually needs, and it is the difference
2238
+ * between a leaked key costing you some tokens and costing you the workspace.
2239
+ */
2240
+ keys = {
2241
+ list: () => this.request("GET", "/keys"),
2242
+ create: (name, opts = {}) => this.request("POST", "/keys", { name, scope: opts.scope ?? "mint" }),
2243
+ revoke: (keyId) => this.request("DELETE", `/keys/${keyId}`),
2244
+ };
2245
+ /** Spend, requests, tokens and latency — including per end-user, since spend is
2246
+ * attributed to the token's subject. */
2247
+ usage = {
2248
+ summary: () => this.request("GET", "/usage"),
2249
+ observability: (windowSeconds = 86_400) => this.request("GET", `/observability?window=${windowSeconds}`),
2250
+ };
2251
+ }
2252
+ /** Factory helper for the server-side client. */
2253
+ export function createProjectClient(opts) {
2254
+ return new OberikProject(opts);
2255
+ }
1738
2256
  /** Factory helper. */
1739
2257
  export function createClient(opts) {
1740
2258
  return new AgentFramework(opts);