@frankieai/server 0.2.2 → 0.2.4

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.
@@ -441,7 +441,6 @@ var OpenApiError = class extends Error {
441
441
  };
442
442
  async function inspectApiCapabilities(source, options = {}) {
443
443
  const doc = await loadSpec(source.spec, options.fetchImpl);
444
- const allowedMethods = new Set(source.methods);
445
444
  const hasPerUserAuth = Boolean(source.forwardUserTokenAs) || source.forwardUserClaims.length > 0;
446
445
  const capabilities = [];
447
446
  for (const [path, item] of Object.entries(doc.paths ?? {})) {
@@ -451,7 +450,6 @@ async function inspectApiCapabilities(source, options = {}) {
451
450
  const operation = item[method];
452
451
  if (!isRecord(operation)) continue;
453
452
  const httpMethod = method.toUpperCase();
454
- if (!allowedMethods.has(httpMethod)) continue;
455
453
  const operationId = typeof operation["operationId"] === "string" ? operation["operationId"] : `${method}_${slug(path)}`;
456
454
  if (source.operations.length > 0 && !source.operations.includes(operationId)) continue;
457
455
  const toolName = `${API_TOOL_PREFIX}${source.name}__${sanitize(operationId)}`;
@@ -482,8 +480,9 @@ async function buildApiTools(options) {
482
480
  }
483
481
  async function buildOne(source, options) {
484
482
  const doc = await loadSpec(source.spec, options.fetchImpl);
483
+ const clientMode = source.execution === "client";
485
484
  const baseUrl = (source.baseUrl ?? specBaseUrl(doc))?.replace(/\/+$/, "");
486
- if (!baseUrl) {
485
+ if (!baseUrl && !clientMode) {
487
486
  throw new OpenApiError(
488
487
  `API "${source.name}" needs a baseUrl: its spec declares no \`servers\` entry to fall back to.`
489
488
  );
@@ -498,17 +497,17 @@ async function buildOne(source, options) {
498
497
  for (const method of ["get", "post", "put", "patch", "delete"]) {
499
498
  const operation = item[method];
500
499
  if (!isRecord(operation)) continue;
501
- if (!allowedMethods.has(method.toUpperCase())) continue;
502
500
  const operationId = typeof operation["operationId"] === "string" ? operation["operationId"] : `${method}_${slug(path)}`;
503
501
  if (source.operations.length > 0 && !source.operations.includes(operationId)) continue;
504
- if (source.selectedTools && !source.selectedTools.includes(operationId)) continue;
502
+ if (source.selectedTools ? !source.selectedTools.includes(operationId) : !allowedMethods.has(method.toUpperCase())) continue;
505
503
  const name = `${API_TOOL_PREFIX}${source.name}__${sanitize(operationId)}`;
506
504
  if (!evaluateToolPolicy(name, options.policy).allowed) continue;
507
505
  const params = collectParameters(doc, item, operation);
508
506
  const body = collectRequestBody(doc, operation);
507
+ const scopeRules = parseScopeRules(operation);
509
508
  const { headers: securedHeaders, query: securityQuery } = applySecurity(doc, source, operation, schemes);
510
509
  const headers = buildForwardedHeaders({ ...source, headers: securedHeaders }, options.session);
511
- const opBaseUrl = resolveOperationBaseUrl(source, doc, item, operation, baseUrl);
510
+ const opBaseUrl = resolveOperationBaseUrl(source, doc, item, operation, baseUrl ?? "");
512
511
  tools.push({
513
512
  name,
514
513
  label: describeTool(name, {}),
@@ -517,7 +516,29 @@ async function buildOne(source, options) {
517
516
  ${retrySafetyNote(method, doc, item, operation)}`,
518
517
  parameters: Type.Unsafe(toolSchema(params, body)),
519
518
  async execute(_id, args, signal) {
519
+ if (clientMode && options.clientBridge?.available) {
520
+ const clientInput = isRecord(args) ? args : {};
521
+ const relPath = buildRelativePath(path, params, clientInput);
522
+ const clientBody = body ? clientInput["body"] : void 0;
523
+ const result = await options.clientBridge.execute(
524
+ name,
525
+ {
526
+ source: source.name,
527
+ method: method.toUpperCase(),
528
+ path: relPath,
529
+ ...clientBody !== void 0 ? { body: clientBody } : {}
530
+ },
531
+ signal ?? AbortSignal.timeout(source.timeoutMs)
532
+ );
533
+ return formatClientResult(operationId, result, source, options.session);
534
+ }
535
+ if (!opBaseUrl) {
536
+ throw new Error(
537
+ `"${operationId}" runs in the user's browser and has no server base URL, so it can't run over the hosted MCP endpoint or a headless caller. Set a baseUrl (and a user-token forwarding header) to expose it that way too. Report this to the user.`
538
+ );
539
+ }
520
540
  const input = enforceIdentityParams(params, isRecord(args) ? args : {}, options.session);
541
+ enforceResourceScope(scopeRules, input, options.session);
521
542
  const url = buildUrl(opBaseUrl, path, params, input, securityQuery);
522
543
  const rawBody = body ? input["body"] : void 0;
523
544
  const requestBody = isRecord(rawBody) ? enforceServerDerivedIdentity(rawBody, options.session) : rawBody;
@@ -531,7 +552,7 @@ ${retrySafetyNote(method, doc, item, operation)}`,
531
552
  ...requestBody !== void 0 ? { body: JSON.stringify(requestBody) } : {},
532
553
  signal: signal ?? AbortSignal.timeout(source.timeoutMs)
533
554
  });
534
- const text = redactSecrets(truncate(await response.text().catch(() => ""), source.maxResponseChars), source, options.session);
555
+ const text = truncate(redactSecrets(await response.text().catch(() => ""), source, options.session), source.maxResponseChars);
535
556
  if (!response.ok) {
536
557
  const detail = `${operationId} failed with HTTP ${response.status}: ${truncate(text, 400)}`;
537
558
  throw new Error(
@@ -691,6 +712,42 @@ function buildUrl(baseUrl, path, params, input, extraQuery = {}) {
691
712
  }
692
713
  return url.toString();
693
714
  }
715
+ function buildRelativePath(path, params, input) {
716
+ let resolved = path;
717
+ for (const param of params) {
718
+ if (param.location !== "path") continue;
719
+ resolved = resolved.replace(`{${param.name}}`, encodeURIComponent(String(input[param.name] ?? "")));
720
+ }
721
+ const query = new URLSearchParams();
722
+ for (const param of params) {
723
+ if (param.location !== "query") continue;
724
+ const value = input[param.name];
725
+ if (value === void 0 || value === null) continue;
726
+ if (Array.isArray(value)) {
727
+ for (const entry of value) query.append(param.name, String(entry));
728
+ } else {
729
+ query.set(param.name, String(value));
730
+ }
731
+ }
732
+ const rel = resolved.startsWith("/") ? resolved.slice(1) : resolved;
733
+ const qs = query.toString();
734
+ return qs ? `${rel}?${qs}` : rel;
735
+ }
736
+ function formatClientResult(operationId, result, source, session) {
737
+ if (result.error) {
738
+ throw new Error(
739
+ `${operationId} could not be reached from the browser: ${truncate(result.error, 300)}. Report this to the user.`
740
+ );
741
+ }
742
+ const text = truncate(redactSecrets(result.body ?? "", source, session), source.maxResponseChars);
743
+ if (result.status < 200 || result.status >= 300) {
744
+ const detail = `${operationId} failed with HTTP ${result.status}: ${truncate(text, 400)}`;
745
+ throw new Error(
746
+ result.status === 401 || result.status === 403 ? `${detail} (authentication/authorization failure). Do not retry by guessing or supplying a different resource ID, header, or credential \u2014 that will not fix an authentication failure. Report the exact error to the user.` : detail
747
+ );
748
+ }
749
+ return { content: [{ type: "text", text: text || "(empty response)" }], details: null };
750
+ }
694
751
  function describeOperation(operation, method, path) {
695
752
  const summary = typeof operation["summary"] === "string" ? operation["summary"] : "";
696
753
  const description = typeof operation["description"] === "string" ? operation["description"] : "";
@@ -766,7 +823,11 @@ function asArray(value) {
766
823
  function isRecord(value) {
767
824
  return typeof value === "object" && value !== null && !Array.isArray(value);
768
825
  }
769
- var IDENTITY_FIELD_PATTERN = /^(user|owner|account|customer|author|created_?by)[_-]?id$/i;
826
+ var IDENTITY_ACTOR = "user|owner|account|customer|author|member|assignee|principal|editor|creator|created_?by|updated_?by|modified_?by";
827
+ var IDENTITY_FIELD_PATTERN = new RegExp(
828
+ `^(?:(?:${IDENTITY_ACTOR})[_-]?id|owner|author|assignee|actor|principal|creator|created_?by|on[_-]?behalf[_-]?of)$`,
829
+ "i"
830
+ );
770
831
  function enforceServerDerivedIdentity(body, session) {
771
832
  return enforceIdentityDeep(body, session);
772
833
  }
@@ -775,7 +836,7 @@ function enforceIdentityDeep(value, session) {
775
836
  if (!isRecord(value)) return value;
776
837
  const next = {};
777
838
  for (const [key, inner] of Object.entries(value)) {
778
- if (IDENTITY_FIELD_PATTERN.test(key)) {
839
+ if (IDENTITY_FIELD_PATTERN.test(key) && !isRecord(inner) && !Array.isArray(inner)) {
779
840
  if (session.user.anonymous) continue;
780
841
  next[key] = session.user.id;
781
842
  } else {
@@ -797,6 +858,39 @@ function enforceIdentityParams(params, input, session) {
797
858
  }
798
859
  return next;
799
860
  }
861
+ function parseScopeRules(operation) {
862
+ const raw = operation["x-frankie-scope"];
863
+ const entries = Array.isArray(raw) ? raw : raw !== void 0 ? [raw] : [];
864
+ const rules = [];
865
+ for (const entry of entries) {
866
+ if (isRecord(entry) && typeof entry["param"] === "string" && typeof entry["claim"] === "string") {
867
+ rules.push({ param: entry["param"], claim: entry["claim"] });
868
+ }
869
+ }
870
+ return rules;
871
+ }
872
+ function enforceResourceScope(rules, input, session) {
873
+ for (const rule of rules) {
874
+ if (session.user.anonymous) {
875
+ throw new Error(
876
+ `"${rule.param}" is access-scoped, but the chat session is anonymous \u2014 Frankie will not call it for an unauthenticated visitor. Report this to the user instead of retrying.`
877
+ );
878
+ }
879
+ const requested = input[rule.param];
880
+ if (requested === void 0 || requested === null || requested === "") {
881
+ throw new Error(
882
+ `This operation requires an in-scope "${rule.param}" and none was given. Report this to the user instead of retrying.`
883
+ );
884
+ }
885
+ const allowed = session.user.claims[rule.claim];
886
+ const allowedList = Array.isArray(allowed) ? allowed.map((entry) => String(entry)) : typeof allowed === "string" ? [allowed] : [];
887
+ if (!allowedList.includes(String(requested))) {
888
+ throw new Error(
889
+ `Access denied: "${String(requested)}" is not in this user's "${rule.claim}" scope. Do not retry with a different id \u2014 report this to the user.`
890
+ );
891
+ }
892
+ }
893
+ }
800
894
 
801
895
  // src/agent/mcp.ts
802
896
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
@@ -830,7 +924,7 @@ async function connectMcpTools(options) {
830
924
  if (result.server.selectedTools && !result.server.selectedTools.includes(tool.name)) continue;
831
925
  const name = `${MCP_TOOL_PREFIX}${result.server.name}__${tool.name}`;
832
926
  if (!evaluateToolPolicy2(name, options.policy).allowed) continue;
833
- tools.push(adaptTool(name, tool, result.client, options.callTimeoutMs));
927
+ tools.push(adaptTool(name, tool, result.client, result.server, options.session, options.callTimeoutMs));
834
928
  }
835
929
  } catch (error) {
836
930
  const message = error instanceof Error ? error.message : String(error);
@@ -863,7 +957,34 @@ async function connectOne(server, options) {
863
957
  }
864
958
  return client;
865
959
  }
866
- function adaptTool(name, tool, client, callTimeoutMs = 6e4) {
960
+ function mcpSecrets(server, session) {
961
+ const secrets = [];
962
+ for (const value of Object.values(server.headers ?? {})) {
963
+ if (!value) continue;
964
+ secrets.push(value);
965
+ secrets.push(Buffer.from(value, "utf8").toString("base64"));
966
+ }
967
+ if (session?.userToken) secrets.push(session.userToken);
968
+ return secrets.sort((a, b) => b.length - a.length);
969
+ }
970
+ function redactString(text, secrets) {
971
+ let out = text;
972
+ for (const value of secrets) out = out.split(value).join("[redacted]");
973
+ return out;
974
+ }
975
+ function redactDeep(value, secrets) {
976
+ if (typeof value === "string") return redactString(value, secrets);
977
+ if (Array.isArray(value)) return value.map((entry) => redactDeep(entry, secrets));
978
+ if (value && typeof value === "object") {
979
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, redactDeep(v, secrets)]));
980
+ }
981
+ return value;
982
+ }
983
+ function redactContent(content, secrets) {
984
+ return content.map((block) => block.type === "text" ? { ...block, text: redactString(block.text, secrets) } : block);
985
+ }
986
+ function adaptTool(name, tool, client, server, session, callTimeoutMs = 6e4) {
987
+ const secrets = mcpSecrets(server, session);
867
988
  return {
868
989
  name,
869
990
  label: describeTool2(name, {}),
@@ -882,11 +1003,11 @@ function adaptTool(name, tool, client, callTimeoutMs = 6e4) {
882
1003
  callTimeoutMs,
883
1004
  `The ${tool.name} tool did not respond in time.`
884
1005
  );
885
- const content = toAgentContent(response.content);
1006
+ const content = redactContent(toAgentContent(response.content), secrets);
886
1007
  if (response.isError) {
887
1008
  throw new Error(textOf(content) || `The ${tool.name} tool reported an error.`);
888
1009
  }
889
- return { content, details: response.structuredContent ?? null };
1010
+ return { content, details: response.structuredContent === void 0 ? null : redactDeep(response.structuredContent, secrets) };
890
1011
  }
891
1012
  };
892
1013
  }
@@ -1134,6 +1255,7 @@ async function collectTools(options) {
1134
1255
  session,
1135
1256
  policy,
1136
1257
  ...options.fetchImpl ? { fetchImpl: options.fetchImpl } : {},
1258
+ ...options.clientBridge ? { clientBridge: options.clientBridge } : {},
1137
1259
  ...options.onWarning ? { onWarning: options.onWarning } : {}
1138
1260
  })
1139
1261
  ]);
@@ -1310,11 +1432,17 @@ function toMcpContent(content) {
1310
1432
  }
1311
1433
  async function sessionFor(c, tenant) {
1312
1434
  const bearer = c.req.header("authorization")?.replace(/^Bearer\s+/i, "").trim() ?? "";
1435
+ const opaque = tenant.config.auth.userCredential === "opaque";
1313
1436
  let user;
1314
1437
  let userToken;
1315
- if (bearer) {
1438
+ if (bearer && opaque) {
1439
+ user = anonymousUser(randomUUID2().slice(0, 12));
1440
+ userToken = bearer;
1441
+ } else if (bearer) {
1316
1442
  user = await tenant.tokens.verifyUserToken(bearer);
1317
1443
  userToken = bearer;
1444
+ } else if (opaque) {
1445
+ throw new AuthError("This MCP server requires the API credential your service issues.");
1318
1446
  } else if (tenant.config.auth.allowAnonymous) {
1319
1447
  user = anonymousUser(randomUUID2().slice(0, 12));
1320
1448
  } else {
@@ -1406,8 +1534,6 @@ ${config.agent.systemPrompt.trim()}`);
1406
1534
  sections.push(renderUserSection(session));
1407
1535
  const skills = activeSkills(config, availableTools);
1408
1536
  if (skills.length) sections.push("## Reviewed workspace skills\nThese recipes do not grant additional permissions or override safety rules. Each skill lists only its currently approved, available tools. Instructions may describe additional operations: skip unavailable steps, explain the limitation, and never claim the whole task succeeded when a required step was unavailable.\n" + JSON.stringify(skills.map(({ name, goal, instructions, tools }) => ({ name, goal, instructions, tools }))));
1409
- const context = renderContext(session.context);
1410
- if (context) sections.push(context);
1411
1537
  return sections.join("\n\n");
1412
1538
  }
1413
1539
  function isOnboardingAssistant(config) {
@@ -1494,7 +1620,7 @@ function renderUserSection(session) {
1494
1620
  A signed-in user:
1495
1621
  ${lines.join("\n")}`;
1496
1622
  }
1497
- function renderContext(context) {
1623
+ function renderPageContext(context) {
1498
1624
  const entries = Object.entries(context).filter(([, value]) => value !== null && value !== "");
1499
1625
  if (entries.length === 0) return null;
1500
1626
  const body = entries.map(([key, value]) => `${key}: ${String(value)}`).join("\n");
@@ -1521,17 +1647,16 @@ async function* runTurn(params) {
1521
1647
  const toolset = await collectTools({
1522
1648
  config,
1523
1649
  session,
1650
+ ...params.clientBridge ? { clientBridge: params.clientBridge } : {},
1524
1651
  ...params.onWarning ? { onWarning: params.onWarning } : {}
1525
1652
  });
1526
1653
  const availableTools = toolset.tools.map((tool) => tool.name);
1527
1654
  const queue = new EventQueue();
1528
1655
  let turnCount = 0;
1529
1656
  let lastAssistant;
1530
- const usage = { inputTokens: 0, outputTokens: 0, costUsd: 0 };
1657
+ const usage = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, costUsd: 0 };
1531
1658
  const { images, documentText } = splitAttachments(params.attachments ?? []);
1532
- const promptText = documentText ? `${params.message}
1533
-
1534
- ${documentText}` : params.message;
1659
+ const promptText = [params.message, documentText, renderPageContext(session.context)].filter((part) => Boolean(part)).join("\n\n");
1535
1660
  const agent = new Agent({
1536
1661
  initialState: {
1537
1662
  systemPrompt: buildSystemPrompt(config, session, availableTools),
@@ -1545,7 +1670,13 @@ ${documentText}` : params.message;
1545
1670
  streamFn: (model, context, options) => params.models.streamSimple(model, context, {
1546
1671
  ...options,
1547
1672
  maxTokens: config.model.maxTokens,
1548
- temperature: config.model.temperature
1673
+ temperature: config.model.temperature,
1674
+ // Ask for prompt caching explicitly rather than relying on the library
1675
+ // default (which an env var can silently turn off). With the session id
1676
+ // the agent already carries, providers that cache by replica affinity
1677
+ // route every turn of a conversation to the same warm replica, and
1678
+ // Anthropic-style providers get cache_control markers on the prefix.
1679
+ cacheRetention: "short"
1549
1680
  }),
1550
1681
  getApiKey: () => params.apiKey,
1551
1682
  sessionId: session.id,
@@ -1576,10 +1707,18 @@ ${documentText}` : params.message;
1576
1707
  usage.inputTokens += event.message.usage.input;
1577
1708
  usage.outputTokens += event.message.usage.output;
1578
1709
  usage.costUsd += event.message.usage.cost?.total ?? 0;
1710
+ usage.cacheReadTokens += event.message.usage.cacheRead ?? 0;
1711
+ usage.cacheWriteTokens += event.message.usage.cacheWrite ?? 0;
1579
1712
  }
1580
1713
  });
1581
1714
  const onAbort = () => agent.abort();
1582
1715
  params.signal.addEventListener("abort", onAbort, { once: true });
1716
+ let transcriptSaved = false;
1717
+ const saveTranscript = () => {
1718
+ if (transcriptSaved) return;
1719
+ transcriptSaved = true;
1720
+ params.onTranscript?.(stripReasoningFromMessages(agent.state.messages));
1721
+ };
1583
1722
  const run = agent.prompt(promptText, images.length ? images : void 0).then(() => void 0).catch((error) => {
1584
1723
  queue.push({
1585
1724
  type: "error",
@@ -1593,13 +1732,16 @@ ${documentText}` : params.message;
1593
1732
  yield { type: "tools_active", apis: toolset.apis };
1594
1733
  for await (const event of queue) yield event;
1595
1734
  await run;
1596
- params.onTranscript?.(stripReasoningFromMessages(agent.state.messages));
1735
+ saveTranscript();
1597
1736
  yield {
1598
1737
  type: "turn_end",
1599
1738
  stopReason: stopReasonFor(params.signal, turnCount, config, lastAssistant),
1600
1739
  ...lastAssistant ? { usage } : {}
1601
1740
  };
1602
1741
  } finally {
1742
+ if (!params.signal.aborted) agent.abort();
1743
+ await run;
1744
+ saveTranscript();
1603
1745
  params.signal.removeEventListener("abort", onAbort);
1604
1746
  unsubscribe();
1605
1747
  await toolset.close();
@@ -1810,8 +1952,9 @@ var EventQueue = class {
1810
1952
  };
1811
1953
 
1812
1954
  // src/app.ts
1813
- import { randomUUID as randomUUID3 } from "crypto";
1955
+ import { randomUUID as randomUUID4 } from "crypto";
1814
1956
  import { Hono as Hono3 } from "hono";
1957
+ import { suggestedPromptsFromSkills } from "@frankieai/core";
1815
1958
 
1816
1959
  // src/guide.ts
1817
1960
  import { Hono as Hono2 } from "hono";
@@ -1904,6 +2047,91 @@ function createGuideApp() {
1904
2047
  return app;
1905
2048
  }
1906
2049
 
2050
+ // src/agent/client-tools.ts
2051
+ import { randomUUID as randomUUID3 } from "crypto";
2052
+ var ClientToolChannel = class {
2053
+ emitters = /* @__PURE__ */ new Map();
2054
+ pending = /* @__PURE__ */ new Map();
2055
+ /** Bind the SSE emitter for a session at the start of its turn. */
2056
+ bindSession(sessionId, emit) {
2057
+ this.emitters.set(sessionId, emit);
2058
+ }
2059
+ /**
2060
+ * Drop a session's emitter at turn end and fail any calls still waiting on the
2061
+ * browser — the stream that would deliver their answer is gone.
2062
+ */
2063
+ releaseSession(sessionId) {
2064
+ this.emitters.delete(sessionId);
2065
+ for (const [key, entry] of this.pending) {
2066
+ if (!key.startsWith(`${sessionId}::`)) continue;
2067
+ this.pending.delete(key);
2068
+ entry.timer && clearTimeout(entry.timer);
2069
+ entry.cleanup();
2070
+ entry.reject(new Error("The chat stream closed before the browser returned a tool result."));
2071
+ }
2072
+ }
2073
+ /** A bridge scoped to one session and turn timeout, handed to the tool builder. */
2074
+ bridge(sessionId, defaultTimeoutMs) {
2075
+ const channel = this;
2076
+ return {
2077
+ get available() {
2078
+ return channel.emitters.has(sessionId);
2079
+ },
2080
+ execute(name, request, signal) {
2081
+ return channel.request(sessionId, name, request, defaultTimeoutMs, signal);
2082
+ }
2083
+ };
2084
+ }
2085
+ request(sessionId, name, request, timeoutMs, signal) {
2086
+ const emit = this.emitters.get(sessionId);
2087
+ if (!emit) {
2088
+ return Promise.reject(
2089
+ new Error(
2090
+ `"${name}" runs in the user's browser, but this session has no live browser channel (for example the hosted MCP endpoint or a headless caller). Report this to the user.`
2091
+ )
2092
+ );
2093
+ }
2094
+ const callId = randomUUID3();
2095
+ const key = `${sessionId}::${callId}`;
2096
+ return new Promise((resolve2, reject) => {
2097
+ const onAbort = () => {
2098
+ this.pending.delete(key);
2099
+ clearTimeout(timer);
2100
+ cleanup();
2101
+ reject(new Error("The turn was aborted before the browser returned a tool result."));
2102
+ };
2103
+ const cleanup = () => signal?.removeEventListener("abort", onAbort);
2104
+ const timer = setTimeout(() => {
2105
+ this.pending.delete(key);
2106
+ cleanup();
2107
+ reject(new Error("The browser did not return a tool result in time."));
2108
+ }, timeoutMs);
2109
+ if (signal?.aborted) {
2110
+ clearTimeout(timer);
2111
+ reject(new Error("The turn was already aborted."));
2112
+ return;
2113
+ }
2114
+ signal?.addEventListener("abort", onAbort, { once: true });
2115
+ this.pending.set(key, { resolve: resolve2, reject, timer, cleanup });
2116
+ emit({ type: "tool_call_request", callId, toolUseId: callId, name, request });
2117
+ });
2118
+ }
2119
+ /**
2120
+ * Deliver a browser's outcome to the waiting call. Returns false when no call
2121
+ * is pending for this (session, callId) — a duplicate, late, or forged result.
2122
+ */
2123
+ resolve(sessionId, callId, result) {
2124
+ const key = `${sessionId}::${callId}`;
2125
+ const entry = this.pending.get(key);
2126
+ if (!entry) return false;
2127
+ this.pending.delete(key);
2128
+ clearTimeout(entry.timer);
2129
+ entry.cleanup();
2130
+ entry.resolve(result);
2131
+ return true;
2132
+ }
2133
+ };
2134
+
1907
2135
  // src/app.ts
1908
2136
  var HEARTBEAT_MS = 15e3;
1909
2137
  function createFrankieApp(options) {
@@ -1913,6 +2141,7 @@ function createFrankieApp(options) {
1913
2141
  const registry = options.resolved ? TenantRegistry.single(options.resolved) : options.tenants instanceof TenantRegistry ? options.tenants : new TenantRegistry(options.tenants);
1914
2142
  const sessions = options.sessions ?? new MemorySessionStore();
1915
2143
  const limiter = options.rateLimiter ?? new MemoryRateLimiter();
2144
+ const clientTools = new ClientToolChannel();
1916
2145
  const log = options.logger ?? ((event) => console.log(JSON.stringify({ ts: (/* @__PURE__ */ new Date()).toISOString(), ...event })));
1917
2146
  const app = new Hono3();
1918
2147
  app.get("/healthz", (c) => c.json({ ok: true }));
@@ -1924,6 +2153,7 @@ function createFrankieApp(options) {
1924
2153
  if (origin) setCors(c, origin);
1925
2154
  return c.body(null, 204);
1926
2155
  }
2156
+ if (origin) setCors(c, origin);
1927
2157
  const key = c.req.header("x-frankie-key") ?? "";
1928
2158
  let tenant;
1929
2159
  try {
@@ -1942,7 +2172,6 @@ function createFrankieApp(options) {
1942
2172
  if (origin && !isOriginAllowed(origin, tenant.config.auth.allowedOrigins)) {
1943
2173
  return jsonError(c, 403, "forbidden_origin", `Origin ${origin} is not allowed for this tenant.`);
1944
2174
  }
1945
- if (origin) setCors(c, origin);
1946
2175
  c.set("tenant", tenant);
1947
2176
  await next();
1948
2177
  return;
@@ -1958,7 +2187,13 @@ function createFrankieApp(options) {
1958
2187
  launcherLabel: config.branding.launcherLabel
1959
2188
  },
1960
2189
  greeting: config.agent.greeting,
1961
- suggestedPrompts: config.agent.suggestedPrompts,
2190
+ // Fall back to prompts derived from the workspace's own published skills,
2191
+ // so an empty chat suggests what this assistant can actually do instead of
2192
+ // nothing. An explicitly authored list always wins.
2193
+ suggestedPrompts: config.agent.suggestedPrompts.length ? config.agent.suggestedPrompts : suggestedPromptsFromSkills(config),
2194
+ // Explicit starter cards configured in the dashboard's Appearance tab.
2195
+ // When set, the widget renders these instead of the suggestedPrompts.
2196
+ starterPrompts: config.agent.starterPrompts,
1962
2197
  theme: config.theme,
1963
2198
  allowAnonymous: config.auth.allowAnonymous,
1964
2199
  showToolActivity: config.agent.showToolActivity,
@@ -1971,7 +2206,10 @@ function createFrankieApp(options) {
1971
2206
  },
1972
2207
  modelPicker: {
1973
2208
  enabled: config.features.modelPicker.enabled,
1974
- models: config.features.modelPicker.models.map((entry) => ({ id: entry.id, label: entry.label }))
2209
+ // The workspace default is always selectable, so enabling the picker
2210
+ // shows a working selector even before any extra models are curated —
2211
+ // otherwise an enabled picker with an empty list renders nothing.
2212
+ models: publicPickerModels(config)
1975
2213
  }
1976
2214
  }
1977
2215
  };
@@ -1990,7 +2228,7 @@ function createFrankieApp(options) {
1990
2228
  if (body.userToken) {
1991
2229
  user = await tokens.verifyUserToken(body.userToken);
1992
2230
  } else if (config.auth.allowAnonymous) {
1993
- user = anonymousUser(randomUUID3().slice(0, 12));
2231
+ user = anonymousUser(randomUUID4().slice(0, 12));
1994
2232
  } else {
1995
2233
  return jsonError(c, 401, "unauthorized", "This assistant requires a signed-in user.");
1996
2234
  }
@@ -2177,19 +2415,21 @@ function createFrankieApp(options) {
2177
2415
  if (config.agent.maxBudgetUsd && session.costUsd >= config.agent.maxBudgetUsd) {
2178
2416
  return jsonError(c, 402, "budget_exceeded", "This conversation has reached its limit.");
2179
2417
  }
2180
- if (body.context) {
2181
- await sessions.update(session.id, { context: { ...session.context, ...sanitizeContext(body.context) } });
2182
- }
2183
- await sessions.update(session.id, {
2418
+ const patch = {
2184
2419
  busy: true,
2185
2420
  messageCount: session.messageCount + 1,
2421
+ ...body.context ? { context: { ...session.context, ...sanitizeContext(body.context) } } : {},
2186
2422
  ...!session.title ? { title: titleFromText(body.message) } : {}
2187
- });
2423
+ };
2424
+ await sessions.update(session.id, patch);
2425
+ const current = { ...session, ...patch };
2188
2426
  const abort = new AbortController();
2189
2427
  const timeout = setTimeout(() => abort.abort(), config.limits.turnTimeoutMs);
2190
2428
  c.req.raw.signal?.addEventListener("abort", () => abort.abort());
2191
2429
  const modelOverride = body.model && body.model !== config.model.model && config.features.modelPicker.enabled ? resolveModelChoice(models, config, body.model) ?? void 0 : void 0;
2192
- const current = await sessions.get(session.id) ?? session;
2430
+ let finalMessages;
2431
+ let turnCostUsd = 0;
2432
+ const clientBridge = clientTools.bridge(session.id, config.limits.turnTimeoutMs);
2193
2433
  const events = runTurn({
2194
2434
  config,
2195
2435
  session: current,
@@ -2197,11 +2437,12 @@ function createFrankieApp(options) {
2197
2437
  apiKey: secrets.providerApiKey,
2198
2438
  model,
2199
2439
  models,
2440
+ clientBridge,
2200
2441
  ...modelOverride ? { modelOverride } : {},
2201
2442
  ...attachments.length ? { attachments } : {},
2202
2443
  signal: abort.signal,
2203
2444
  onTranscript: (messages) => {
2204
- void sessions.update(session.id, { messages });
2445
+ finalMessages = messages;
2205
2446
  },
2206
2447
  onWarning: (event, detail) => log({ level: "warn", event, sessionId: session.id, ...detail })
2207
2448
  });
@@ -2214,14 +2455,13 @@ function createFrankieApp(options) {
2214
2455
  `));
2215
2456
  };
2216
2457
  const heartbeat = setInterval(() => send({ type: "ping" }), HEARTBEAT_MS);
2458
+ clientTools.bindSession(session.id, send);
2217
2459
  send({ type: "session", sessionId: session.id, resumed: current.messages.length > 0 });
2218
2460
  try {
2219
2461
  for await (const event of events) {
2220
- if (event.type === "turn_end" && event.usage?.costUsd !== void 0) {
2221
- const latest = await sessions.get(session.id);
2222
- await sessions.update(session.id, {
2223
- costUsd: (latest?.costUsd ?? current.costUsd) + event.usage.costUsd
2224
- });
2462
+ if (event.type === "turn_end" && event.usage) {
2463
+ if (event.usage.costUsd !== void 0) turnCostUsd += event.usage.costUsd;
2464
+ log({ level: "info", event: "turn_usage", sessionId: session.id, ...event.usage });
2225
2465
  }
2226
2466
  send(event);
2227
2467
  }
@@ -2236,14 +2476,18 @@ function createFrankieApp(options) {
2236
2476
  } finally {
2237
2477
  clearInterval(heartbeat);
2238
2478
  clearTimeout(timeout);
2239
- await sessions.update(session.id, { busy: false });
2479
+ clientTools.releaseSession(session.id);
2480
+ await sessions.update(session.id, {
2481
+ busy: false,
2482
+ ...finalMessages ? { messages: finalMessages } : {},
2483
+ ...turnCostUsd ? { costUsd: current.costUsd + turnCostUsd } : {}
2484
+ });
2240
2485
  controller.close();
2241
2486
  }
2242
2487
  },
2243
2488
  cancel() {
2244
2489
  clearTimeout(timeout);
2245
2490
  abort.abort();
2246
- void sessions.update(session.id, { busy: false });
2247
2491
  }
2248
2492
  });
2249
2493
  const origin = c.req.header("origin");
@@ -2257,13 +2501,46 @@ function createFrankieApp(options) {
2257
2501
  }
2258
2502
  });
2259
2503
  });
2504
+ app.post("/v1/chat/tool-result", async (c) => {
2505
+ const { tokens } = c.get("tenant");
2506
+ const bearer = c.req.header("authorization")?.replace(/^Bearer\s+/i, "") ?? "";
2507
+ let claims;
2508
+ try {
2509
+ claims = await tokens.verifySessionToken(bearer);
2510
+ } catch (error) {
2511
+ return jsonError(c, 401, "unauthorized", error instanceof AuthError ? error.message : "Unauthorized.");
2512
+ }
2513
+ const body = await readJson(c);
2514
+ if (!body || typeof body.callId !== "string" || !isHostToolResult(body.result)) {
2515
+ return jsonError(c, 400, "internal", "A tool result needs a `callId` and a `result`.");
2516
+ }
2517
+ const delivered = clientTools.resolve(claims.sessionId, body.callId, body.result);
2518
+ if (!delivered) {
2519
+ return jsonError(c, 404, "session_not_found", "No tool call is awaiting this result.");
2520
+ }
2521
+ return c.json({ ok: true });
2522
+ });
2260
2523
  return app;
2261
2524
  }
2525
+ function isHostToolResult(value) {
2526
+ if (typeof value !== "object" || value === null) return false;
2527
+ const result = value;
2528
+ if (typeof result["status"] !== "number") return false;
2529
+ if (result["body"] !== void 0 && typeof result["body"] !== "string") return false;
2530
+ if (result["error"] !== void 0 && typeof result["error"] !== "string") return false;
2531
+ return true;
2532
+ }
2262
2533
  function mimeAccepted(mimeType, accept) {
2263
2534
  return accept.some(
2264
2535
  (pattern) => pattern.endsWith("/*") ? mimeType.startsWith(pattern.slice(0, -1)) : pattern === mimeType
2265
2536
  );
2266
2537
  }
2538
+ function publicPickerModels(config) {
2539
+ const curated = config.features.modelPicker.models.map((entry) => ({ id: entry.id, label: entry.label }));
2540
+ if (!config.features.modelPicker.enabled) return curated;
2541
+ if (curated.some((model) => model.id === config.model.model)) return curated;
2542
+ return [{ id: config.model.model, label: "Default" }, ...curated];
2543
+ }
2267
2544
  function base64Bytes(data) {
2268
2545
  const padding = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0;
2269
2546
  return Math.floor(data.length * 3 / 4) - padding;
@@ -2354,10 +2631,14 @@ function titleFromClientMessages(messages) {
2354
2631
 
2355
2632
  // src/control/store.ts
2356
2633
  import { mkdir, readFile as readFile2, readdir, rename, rm, writeFile } from "fs/promises";
2357
- import { randomUUID as randomUUID4 } from "crypto";
2634
+ import { randomUUID as randomUUID5 } from "crypto";
2358
2635
  import { join, resolve } from "path";
2359
2636
  import { parseFrankieConfig } from "@frankieai/core";
2360
2637
  var MEMBERS_FILE = "__members.json";
2638
+ function publicVercelLink(link) {
2639
+ const { accessToken: _token, ...rest } = link;
2640
+ return rest;
2641
+ }
2361
2642
  function toPublicTenant(record) {
2362
2643
  const { config } = record;
2363
2644
  return {
@@ -2371,6 +2652,7 @@ function toPublicTenant(record) {
2371
2652
  publishableKey: config.auth.publishableKey,
2372
2653
  allowedOrigins: config.auth.allowedOrigins,
2373
2654
  allowAnonymous: config.auth.allowAnonymous,
2655
+ userCredential: config.auth.userCredential,
2374
2656
  connection: config.apis.length > 0 ? "openapi" : config.mcpServers.length > 0 ? "mcp" : "knowledge",
2375
2657
  mcpServers: config.mcpServers.map((s) => s.name),
2376
2658
  apis: config.apis.map((a) => a.name),
@@ -2378,6 +2660,7 @@ function toPublicTenant(record) {
2378
2660
  ...config.mcpServers.map((s) => ({
2379
2661
  kind: "mcp",
2380
2662
  name: s.name,
2663
+ ...s.displayName ? { displayName: s.displayName } : {},
2381
2664
  url: s.url,
2382
2665
  discoveredTools: s.discoveredTools,
2383
2666
  ...s.selectedTools ? { selectedTools: s.selectedTools } : {},
@@ -2388,13 +2671,16 @@ function toPublicTenant(record) {
2388
2671
  ...config.apis.map((a) => ({
2389
2672
  kind: "openapi",
2390
2673
  name: a.name,
2674
+ ...a.displayName ? { displayName: a.displayName } : {},
2391
2675
  spec: a.spec,
2392
2676
  ...a.baseUrl ? { baseUrl: a.baseUrl } : {},
2393
2677
  methods: a.methods,
2394
2678
  ...a.selectedTools ? { selectedTools: a.selectedTools } : {},
2395
2679
  exposeAsMcp: a.exposeAsMcp,
2680
+ execution: a.execution,
2396
2681
  capabilities: a.capabilities.map((capability) => ({ ...capability })),
2397
2682
  forwardsToken: Boolean(a.forwardUserTokenAs),
2683
+ ...a.forwardUserTokenAs ? { forwardTokenAs: a.forwardUserTokenAs } : {},
2398
2684
  securedSchemes: Object.keys(a.security ?? {}),
2399
2685
  enabled: a.enabled && config.agent.tools.allow.some((entry) => entry === `api__${a.name}` || entry.startsWith(`api__${a.name}__`)),
2400
2686
  status: a.capabilities.length ? "ready" : "needs_attention"
@@ -2402,8 +2688,14 @@ function toPublicTenant(record) {
2402
2688
  ],
2403
2689
  locked: record.locked ?? false,
2404
2690
  ...record.repositoryInstall ? { repositoryInstall: record.repositoryInstall } : {},
2691
+ ...record.vercel ? { vercel: publicVercelLink(record.vercel) } : {},
2405
2692
  theme: config.theme,
2406
2693
  features: config.features,
2694
+ agent: {
2695
+ greeting: config.agent.greeting,
2696
+ suggestedPrompts: config.agent.suggestedPrompts,
2697
+ starterPrompts: config.agent.starterPrompts
2698
+ },
2407
2699
  createdAt: record.createdAt,
2408
2700
  updatedAt: record.updatedAt
2409
2701
  };
@@ -2436,7 +2728,7 @@ var FileTenantStore = class _FileTenantStore {
2436
2728
  async putPlatformAdmin(admin) {
2437
2729
  const path = this.adminPath(admin.ownerId);
2438
2730
  await mkdir(join(this.dir, "platform-admins"), { recursive: true });
2439
- const temporary = path + "." + randomUUID4() + ".tmp";
2731
+ const temporary = path + "." + randomUUID5() + ".tmp";
2440
2732
  await writeFile(temporary, JSON.stringify(admin), { mode: 384 });
2441
2733
  await rename(temporary, path);
2442
2734
  }
@@ -2915,4 +3207,4 @@ export {
2915
3207
  resolveStores,
2916
3208
  resolveTenantStore
2917
3209
  };
2918
- //# sourceMappingURL=chunk-JOADNB5J.js.map
3210
+ //# sourceMappingURL=chunk-7J5Z5MIK.js.map