@zackbart/connecta 0.16.1 → 0.17.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/dist/catalog-service.d.ts +4 -0
  3. package/dist/catalog-service.js +8 -1
  4. package/dist/catalog.js +114 -12
  5. package/dist/errors.d.ts +4 -6
  6. package/dist/execute.d.ts +5 -0
  7. package/dist/execute.js +229 -161
  8. package/dist/invocation.js +3 -1
  9. package/dist/meta-tools.d.ts +4 -0
  10. package/dist/meta-tools.js +46 -14
  11. package/dist/operator-ui/generated.d.ts +1 -1
  12. package/dist/operator-ui/generated.js +1 -1
  13. package/dist/operator-ui/model.d.ts +3 -1
  14. package/dist/providers/mixpanel.d.ts +3 -5
  15. package/dist/providers/mixpanel.js +73 -5
  16. package/dist/providers/stripe.d.ts +2 -2
  17. package/dist/providers/stripe.js +13 -11
  18. package/dist/registry.d.ts +32 -9
  19. package/dist/registry.js +217 -33
  20. package/dist/routes/mcp.js +6 -0
  21. package/dist/skills.d.ts +4 -0
  22. package/dist/skills.js +157 -18
  23. package/dist/types.d.ts +14 -2
  24. package/dist/ui.js +4 -1
  25. package/dist/version.d.ts +1 -1
  26. package/dist/version.js +1 -1
  27. package/documentation/architecture.md +7 -4
  28. package/documentation/code-mode.md +45 -53
  29. package/documentation/connector-guides.md +24 -19
  30. package/documentation/connectors.md +13 -1
  31. package/documentation/meta-tools.md +26 -17
  32. package/documentation/mixpanel.md +20 -0
  33. package/documentation/operations.md +21 -18
  34. package/documentation/operator-ui.md +12 -2
  35. package/documentation/provider-audit.md +3 -3
  36. package/documentation/provider-conventions.md +26 -13
  37. package/documentation/stripe.md +45 -14
  38. package/documentation/upgrading.md +28 -4
  39. package/ethos.md +3 -3
  40. package/examples/worker/README.md +4 -3
  41. package/package.json +1 -1
  42. package/templates/node/package.json +1 -1
@@ -234,6 +234,9 @@ async function serveMcp(request, opts, baseUrl, actor, registry, runtimeContext)
234
234
  ? { discoveryConcurrency: opts.discoveryConcurrency }
235
235
  : {}),
236
236
  requestSignal: request.signal,
237
+ ...(runtimeContext?.waitUntil
238
+ ? { defer: runtimeContext.waitUntil.bind(runtimeContext) }
239
+ : {}),
237
240
  });
238
241
  registerExecuteTool(server, registry, {
239
242
  baseUrl,
@@ -241,6 +244,9 @@ async function serveMcp(request, opts, baseUrl, actor, registry, runtimeContext)
241
244
  logger: opts.logger,
242
245
  ...(activity ? { activity } : {}),
243
246
  requestSignal: request.signal,
247
+ ...(runtimeContext?.waitUntil
248
+ ? { defer: runtimeContext.waitUntil.bind(runtimeContext) }
249
+ : {}),
244
250
  ...(opts.discoveryConcurrency !== undefined
245
251
  ? { discoveryConcurrency: opts.discoveryConcurrency }
246
252
  : {}),
package/dist/skills.d.ts CHANGED
@@ -10,6 +10,10 @@ export declare function hasConnectorGuides(connectors: readonly Connector[]): bo
10
10
  export declare function connectorSkillName(connectorId: string): string;
11
11
  /** The connector's guide, or undefined when it declares none (or a blank one). */
12
12
  export declare function connectorGuide(connector: Connector): string | undefined;
13
+ /** Discovery budget for one connector-guide summary, including an ellipsis. */
14
+ export declare const GUIDE_SUMMARY_LENGTH = 120;
15
+ /** Normalize authored and derived summaries under one construction contract. */
16
+ export declare function normalizeGuideSummary(summary: string): string | undefined;
13
17
  /** Bounded, decision-useful discovery summary for a connector guide. */
14
18
  export declare function connectorGuideSummary(connector: Connector): string | undefined;
15
19
  /** Whether correct use always depends on conventions outside the tool schema. */
package/dist/skills.js CHANGED
@@ -17,24 +17,24 @@ Use exact addresses from discovery; never invent one. Search 2–4 distinctive a
17
17
 
18
18
  ## Inside a program
19
19
 
20
- One async arrow function. The only capabilities are one global per connector (\`<connectorId>.<toolName>(args)\`), the \`connecta\` functions, and \`console.log\`.
20
+ Portable code uses only connector globals (\`<connectorId>.<toolName>(args)\`), \`connecta\`, and \`console.*\`. QuickJS blocks imports and lacks fetch/process/timers/crypto/WebSocket. Dynamic Workers require only \`{ loader }\`; bindings/modules/globalOutbound violate it. Then env maps are empty; node:fs/http/https absent; outbound fetch/WebSocket/node:net/tls denied; DNS unresolved. Runtime builtins remain through import() and process.getBuiltinModule(), including node:path and cloudflare:workers; the set can drift. Timers/process/crypto/WebSocket and data: fetch remain. Avoid them; QuickJS fails.
21
21
 
22
22
  - \`connecta.search({})\` loads all catalogs; pass \`connector: "<id>"\` when obvious to load one. \`safety: "readOnly"\` keeps executable calls. Neither grants authority. Matches carry \`address\` and annotations.
23
23
  - Exact schemas: \`connecta.describe({ address: "connector.tool" })\` for one, \`{ addresses: [...] }\` for many; \`format: "json"\` only for exact constraints.
24
- - Two to ten independent calls: \`connecta.batch([...])\`. Each outcome is \`{ address, ok: true, data }\` or \`{ address, ok: false, error, errorDetails: { code, retryable } }\` how a program tells a policy refusal from a transient failure.
25
- - Search inside the run, not before it; return only the reduction the answer needs, never raw payloads.
24
+ - Caught Connecta errors have \`message\`, \`code\`, \`retryable\`, and \`details\`; branch on fields. For 2–10 independent calls, \`connecta.batch([...])\` returns success data or an \`errorDetails\` whose code and retryable flag match the throw.
25
+ - Search inside the run; return only the reduction the answer needs, never raw payloads.
26
26
  - Only tools annotated \`readOnlyHint: true\` are reachable; the gate, credentials, and admission are enforced below the sandbox — nothing a program does widens its reach.
27
27
 
28
28
  ## Rendering a view
29
29
 
30
- \`connecta.ui(html)\` renders a display-only view on success for the client, never for the model. Fetch first, check the shape in code. On a surprise — empty array, missing key return a trimmed first record instead of rendering: the wrong view becomes the sample you needed. Otherwise render from the variables you return; the model reads the return value, not the view.
30
+ \`connecta.ui(html)\` renders one success-only display view, never for the model. Fetch and check the shape first. On empty or missing data, return a trimmed first record instead of rendering. Otherwise render returned variables; the model reads the return value, not the view.
31
31
 
32
32
  `;
33
33
  /** Deployment-scoped guide routing appended to the shared usage guide. */
34
34
  const CONNECTOR_GUIDES_SECTION = `
35
35
  ## Per-connector guides
36
36
 
37
- When a connector here ships a deployment-scoped usage guide, \`skills({})\` and discovery return the exact \`guide\` name plus a bounded \`guideSummary\` saying what it covers. Fetch only a listed or carried name with \`skills({ name: <guide> })\`; never infer one from a connector id. \`guideRequired: true\` is a hard stop: fetch before calling. \`guideRequiredReasons\` says why \`connector_required\` and \`approval_required\` stand however you expand the schema; \`schema_truncated\` clears once describe returns the exact one. Otherwise fetch when the summary names a connector-specific sequence, unit, pagination rule, alias, or generic API convention relevant to the task. A read-only call whose compact schema is complete and unambiguous may proceed without fetching an otherwise irrelevant guide. Connector guides do not replace the shared Connecta usage guide and never apply to another deployment implicitly.
37
+ Connector guides appear in \`skills({})\` and discovery with an exact \`guide\` name and bounded \`guideSummary\`. Fetch only a listed or carried name with \`skills({ name: <guide> })\`; never infer one from a connector id. \`guideRequired: true\` is a hard stop. \`guideRequiredReasons\` says why: \`connector_required\` and \`approval_required\` stand after schema expansion; \`schema_truncated\` clears after exact describe. Otherwise fetch for a relevant sequence, unit, pagination rule, alias, or API convention. A read-only call with a complete compact schema may skip an irrelevant guide. Connector guides never apply to another deployment.
38
38
  `;
39
39
  /** Shared Connecta routing guidance, byte-identical across deployments. */
40
40
  export const USAGE_SKILL = USAGE_SKILL_BASE + CONNECTOR_GUIDES_SECTION;
@@ -80,17 +80,63 @@ export function connectorGuide(connector) {
80
80
  const content = typeof guide === "string" ? guide : guide?.content;
81
81
  return content && content.trim() !== "" ? content : undefined;
82
82
  }
83
- const SUMMARY_LENGTH = 120;
83
+ /** Discovery budget for one connector-guide summary, including an ellipsis. */
84
+ export const GUIDE_SUMMARY_LENGTH = 120;
84
85
  /** A `---`/`***`/`___` rule, which also opens and closes YAML frontmatter. */
85
86
  const RULE_RE = /^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/;
86
87
  /** A fenced code block's delimiter. */
87
88
  const FENCE_RE = /^\s*(?:```|~~~)/;
89
+ /** Markdown blocks that end a paragraph without a blank physical line. */
90
+ const HEADING_RE = /^\s*#{1,6}/;
91
+ const LIST_ITEM_RE = /^\s*(?:[-*+]\s+|\d+[.)]\s+)/;
92
+ const SETEXT_UNDERLINE_RE = /^\s*=+\s*$/;
93
+ const TABLE_DELIMITER_RE = /^\s*\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?\s*$/;
94
+ const OPENS_CLAUSE_RE = /^(?:The|A|An)\b/u;
95
+ const ARTICLE_RE = /^(?:the|a|an)\b/u;
88
96
  /**
89
97
  * Markup that carries no summary text of its own: horizontal rules, HTML
90
98
  * comments, and table rows. Skipped so a guide that opens with one is
91
99
  * summarized by its first real line instead of by punctuation.
92
100
  */
93
101
  const NOT_SUMMARY_RE = /^\s*(?:<!--|\|)|^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/;
102
+ /** A standard Markdown table starts with a pipe-bearing row and delimiter. */
103
+ function startsTable(lines, index) {
104
+ const header = lines[index] ?? "";
105
+ const delimiter = lines[index + 1] ?? "";
106
+ return header.includes("|") && TABLE_DELIMITER_RE.test(delimiter);
107
+ }
108
+ /** True when a sentence-looking period belongs to an abbreviation. */
109
+ function isAbbreviation(text, end) {
110
+ const token = text.slice(0, end).match(/\S+$/u)?.[0] ?? "";
111
+ // These introduce an example or restatement even before a capitalized word.
112
+ if (/^(?:e\.g|i\.e)\.$/iu.test(token))
113
+ return true;
114
+ // An initial or title belongs to the proper name that follows it.
115
+ if (/^[A-Z]\.$/u.test(token))
116
+ return true;
117
+ if (/^(?:Mr|Mrs|Ms|Dr|Prof|Sr|Jr|St)\.$/iu.test(token))
118
+ return true;
119
+ if (!/^(?:[A-Za-z]\.){2,}$/u.test(token) &&
120
+ !/^(?:vs|etc|approx|dept|fig|no)\.$/iu.test(token)) {
121
+ return false;
122
+ }
123
+ // Initialisms can end a sentence or extend a name ("U.S. East region").
124
+ // The mistakes are asymmetric: a false ending presents a fragment as a
125
+ // complete thought, while a missed ending gets an honest ellipsis. Count
126
+ // the period only with narrow evidence of a new clause: an article in one
127
+ // of its first two words. This is grammar evidence, not a starter-word list.
128
+ const following = text
129
+ .slice(end)
130
+ .match(/^[)\]}'"”’]*\s+(\S+)(?:\s+(\S+))?/u);
131
+ if (!following)
132
+ return false;
133
+ const [, nextWord, afterNext] = following;
134
+ const startsClause = nextWord !== undefined &&
135
+ /^\p{Lu}/u.test(nextWord) &&
136
+ (OPENS_CLAUSE_RE.test(nextWord) ||
137
+ (afterNext !== undefined && ARTICLE_RE.test(afterNext)));
138
+ return !startsClause;
139
+ }
94
140
  /** Drop a leading YAML frontmatter block — metadata, not summary text. */
95
141
  function withoutFrontmatter(lines) {
96
142
  let start = 0;
@@ -102,33 +148,107 @@ function withoutFrontmatter(lines) {
102
148
  const close = lines.findIndex((line, i) => i > start && RULE_RE.test(line));
103
149
  return close === -1 ? lines : lines.slice(close + 1);
104
150
  }
151
+ /** Normalize authored and derived summaries under one construction contract. */
152
+ export function normalizeGuideSummary(summary) {
153
+ const normalized = summary.replace(/\s+/g, " ").trim();
154
+ return normalized === "" ? undefined : normalized;
155
+ }
105
156
  /**
106
- * One line describing a guide, for the cheap list view: the guide's first
107
- * meaningful line (heading marks and list bullets stripped), falling back to
108
- * the connector's own description when the guide opens with nothing but
109
- * markup.
157
+ * Shorten a normalized summary at the strongest readable boundary available.
158
+ * A complete sentence needs no ellipsis; clause and word cuts do, so discovery
159
+ * never presents an unfinished fragment as the guide's complete thought.
110
160
  */
111
161
  function boundedSummary(summary) {
112
- const line = summary.replace(/\s+/g, " ").trim();
113
- if (line === "")
162
+ const normalized = normalizeGuideSummary(summary);
163
+ if (!normalized)
114
164
  return undefined;
115
- return line.length <= SUMMARY_LENGTH
116
- ? line
117
- : `${line.slice(0, SUMMARY_LENGTH - 1).trimEnd()}…`;
165
+ if (normalized.length <= GUIDE_SUMMARY_LENGTH)
166
+ return normalized;
167
+ const contentBudget = GUIDE_SUMMARY_LENGTH - 1;
168
+ let sentenceEnd = 0;
169
+ const sentenceBoundary = /[.!?…。!?](?:[)\]}'"”’]+)?(?=\s|$)/gu;
170
+ for (const match of normalized.matchAll(sentenceBoundary)) {
171
+ const end = (match.index ?? 0) + match[0].length;
172
+ if (end > GUIDE_SUMMARY_LENGTH)
173
+ break;
174
+ const punctuationEnd = (match.index ?? 0) + 1;
175
+ if (match[0].startsWith(".") &&
176
+ isAbbreviation(normalized, punctuationEnd)) {
177
+ continue;
178
+ }
179
+ // Do not mistake another short fragment for a useful complete thought.
180
+ if (end >= 24)
181
+ sentenceEnd = end;
182
+ }
183
+ if (sentenceEnd > 0)
184
+ return normalized.slice(0, sentenceEnd);
185
+ const available = normalized.slice(0, contentBudget);
186
+ let clauseEnd = 0;
187
+ const clauseBoundary = /[,;:](?=\s)|\s[—–-](?=\s)/g;
188
+ for (const match of available.matchAll(clauseBoundary)) {
189
+ const end = match.index ?? 0;
190
+ // Prefer a clause only when it retains most of the discovery budget.
191
+ if (end >= 80)
192
+ clauseEnd = end;
193
+ }
194
+ if (clauseEnd > 0) {
195
+ return `${available.slice(0, clauseEnd).trimEnd()}…`;
196
+ }
197
+ const wordEnd = available.search(/\s+\S*$/);
198
+ if (wordEnd > 0) {
199
+ const prefix = available
200
+ .slice(0, wordEnd)
201
+ .trimEnd()
202
+ .replace(/[,;:([{—–-]+$/u, "")
203
+ .trimEnd();
204
+ if (prefix !== "")
205
+ return `${prefix}…`;
206
+ }
207
+ let hardEnd = contentBudget;
208
+ const code = normalized.charCodeAt(hardEnd - 1);
209
+ if (code >= 0xd800 && code <= 0xdbff)
210
+ hardEnd--;
211
+ return `${normalized.slice(0, hardEnd)}…`;
118
212
  }
213
+ /**
214
+ * One thought describing a guide for the cheap list view: the first meaningful
215
+ * paragraph, joined across physical lines, with headings and the connector
216
+ * description as fallbacks when the guide opens with markup alone.
217
+ */
119
218
  function summarizeGuide(connector, guide) {
219
+ const lines = withoutFrontmatter(guide.split("\n"));
120
220
  let inFence = false;
221
+ let inComment = false;
121
222
  let headingFallback;
122
- for (const raw of withoutFrontmatter(guide.split("\n"))) {
223
+ for (let index = 0; index < lines.length; index++) {
224
+ const raw = lines[index] ?? "";
225
+ if (inComment) {
226
+ if (raw.includes("-->"))
227
+ inComment = false;
228
+ continue;
229
+ }
123
230
  if (FENCE_RE.test(raw)) {
124
231
  inFence = !inFence;
125
232
  continue;
126
233
  }
127
234
  if (inFence)
128
235
  continue;
236
+ if (raw.trimStart().startsWith("<!--")) {
237
+ if (!raw.includes("-->"))
238
+ inComment = true;
239
+ continue;
240
+ }
241
+ if (startsTable(lines, index)) {
242
+ index++;
243
+ while (index + 1 < lines.length &&
244
+ (lines[index + 1] ?? "").includes("|")) {
245
+ index++;
246
+ }
247
+ continue;
248
+ }
129
249
  if (raw.trim() === "" || NOT_SUMMARY_RE.test(raw))
130
250
  continue;
131
- const heading = /^\s*#{1,6}/.test(raw);
251
+ const heading = HEADING_RE.test(raw);
132
252
  const line = raw
133
253
  // `\s*` (not `\s+`) so a bare `#` strips to nothing and is skipped, and
134
254
  // an unspaced `#Heading` is still read as a heading.
@@ -142,7 +262,24 @@ function summarizeGuide(connector, guide) {
142
262
  headingFallback ??= boundedSummary(line);
143
263
  continue;
144
264
  }
145
- return boundedSummary(line) ?? line;
265
+ const paragraph = [line];
266
+ while (index + 1 < lines.length) {
267
+ const next = lines[index + 1] ?? "";
268
+ if (next.trim() === "" ||
269
+ startsTable(lines, index + 1) ||
270
+ FENCE_RE.test(next) ||
271
+ HEADING_RE.test(next) ||
272
+ LIST_ITEM_RE.test(next) ||
273
+ SETEXT_UNDERLINE_RE.test(next) ||
274
+ NOT_SUMMARY_RE.test(next)) {
275
+ break;
276
+ }
277
+ paragraph.push(next.trim());
278
+ index++;
279
+ if (paragraph.join(" ").length > GUIDE_SUMMARY_LENGTH)
280
+ break;
281
+ }
282
+ return boundedSummary(paragraph.join(" ")) ?? line;
146
283
  }
147
284
  if (headingFallback)
148
285
  return headingFallback;
@@ -155,6 +292,8 @@ export function connectorGuideSummary(connector) {
155
292
  if (!guide)
156
293
  return undefined;
157
294
  const configured = typeof connector.usageGuide === "object"
295
+ // Registry construction rejects over-budget configured summaries. Keep
296
+ // normalization here so direct Connector callers see the same text.
158
297
  ? boundedSummary(connector.usageGuide.summary ?? "")
159
298
  : undefined;
160
299
  return configured ?? summarizeGuide(connector, guide);
package/dist/types.d.ts CHANGED
@@ -180,6 +180,12 @@ export interface CatalogDriftReport extends CatalogDriftCounts {
180
180
  /** When the observation was taken; never when a probe was scheduled. */
181
181
  observedAt: string;
182
182
  }
183
+ /** The last agent-facing catalog read this runtime served for one connector. */
184
+ export interface CatalogAccessObservation {
185
+ /** Fresh means the read used a live or unexpired catalog; stale means SWR. */
186
+ state: "fresh" | "stale";
187
+ observedAt: string;
188
+ }
183
189
  export interface ConnectorStatus {
184
190
  state: ConnectorStatusState;
185
191
  /** When state === "auth_required", the URL the operator should open. */
@@ -193,6 +199,11 @@ export interface ConnectorStatus {
193
199
  * isolate or process has seen nothing, not that nothing drifted.
194
200
  */
195
201
  catalogDrift?: CatalogDriftReport;
202
+ /**
203
+ * The last agent-facing catalog read in this runtime. This payload-free
204
+ * observation is not persisted and operator reads do not replace it.
205
+ */
206
+ catalogAccess?: CatalogAccessObservation;
196
207
  }
197
208
  /** The whole plugin contract — the one open seam. */
198
209
  export interface Connector {
@@ -313,8 +324,9 @@ export interface ConnectorUsageGuide {
313
324
  /** Markdown returned verbatim by `skills({ name: "connector:<id>" })`. */
314
325
  content: string;
315
326
  /**
316
- * Bounded discovery hint describing the conventions the guide covers. When
317
- * omitted, Connecta derives a summary from the guide's first meaningful line.
327
+ * Discovery hint describing the conventions the guide covers. Whitespace is
328
+ * normalized, and a value over 120 characters throws at construction. When
329
+ * omitted, Connecta derives a bounded summary from the first body paragraph.
318
330
  */
319
331
  summary?: string;
320
332
  /**
package/dist/ui.js CHANGED
@@ -374,6 +374,9 @@ export async function buildUiData(registry, baseUrl, serverInfo, credentialVault
374
374
  // `boundedCatalogDrift`, so what lands here cannot carry a name or a
375
375
  // schema even if the plugin seam returned one.
376
376
  ...(status.catalogDrift ? { catalogDrift: status.catalogDrift } : {}),
377
+ ...(status.catalogAccess
378
+ ? { catalogAccess: status.catalogAccess }
379
+ : {}),
377
380
  ...(c.disconnectAuth && c.startAuth ? { oauth: true } : {}),
378
381
  ...(credential ? { credential } : {}),
379
382
  };
@@ -461,7 +464,7 @@ export function renderUiHtml(uiAuth, mcpUrl = "/mcp", branding, nonce, page = "c
461
464
  : `<span class="product">${escapeHtmlAttr(brand.productName)}</span>`
462
465
  : "";
463
466
  const clerkScript = clerk && clerkScriptOrigin
464
- ? `<script${nonceAttr} defer crossorigin="anonymous" data-clerk-publishable-key="${escapeHtmlAttr(clerk.publishableKey)}" src="${escapeHtmlAttr(clerkScriptOrigin)}/npm/@clerk/clerk-js@6/dist/clerk.browser.js"></script>`
467
+ ? `<script${nonceAttr} crossorigin="anonymous" data-clerk-publishable-key="${escapeHtmlAttr(clerk.publishableKey)}" src="${escapeHtmlAttr(clerkScriptOrigin)}/npm/@clerk/clerk-js@6/dist/clerk.browser.js"></script>`
465
468
  : "";
466
469
  return `<!doctype html>
467
470
  <html lang="en">
package/dist/version.d.ts CHANGED
@@ -4,4 +4,4 @@
4
4
  * a bump that forgets this file fails the build rather than shipping a stale
5
5
  * version to `/health` and to downstream MCP handshakes.
6
6
  */
7
- export declare const CONNECTA_VERSION = "0.16.1";
7
+ export declare const CONNECTA_VERSION = "0.17.0";
package/dist/version.js CHANGED
@@ -4,4 +4,4 @@
4
4
  * a bump that forgets this file fails the build rather than shipping a stale
5
5
  * version to `/health` and to downstream MCP handshakes.
6
6
  */
7
- export const CONNECTA_VERSION = "0.16.1";
7
+ export const CONNECTA_VERSION = "0.17.0";
@@ -26,7 +26,10 @@ ethos invariant, not a style preference: a client retained across requests on
26
26
  Workers is a cross-request capability leak, and a promise awaited after the
27
27
  response is work the runtime may have already torn down. Deferred work has one
28
28
  sanctioned channel — `ctx.waitUntil`, threaded through `fetch(request, env,
29
- ctx)` and used for best-effort activity writes.
29
+ ctx)`. Best-effort activity writes use it. An agent read that already demanded
30
+ an expired catalog refresh may also use it while serving a complete catalog
31
+ inside its stale window. That refresh owns a new scope and deadline; it never
32
+ carries the inbound scope or signal past the request.
30
33
 
31
34
  The registry is deliberately on the long side of that line and the MCP server
32
35
  deliberately on the short side. A fresh `McpServer` per request is what makes
@@ -87,8 +90,8 @@ owns or hands out, and a change usually belongs in exactly one of them:
87
90
 
88
91
  | Module | Owns |
89
92
  | --- | --- |
90
- | `src/registry.ts` | The connector set, id validation, address resolution, connector health, per-connector call limiters, and the drift snapshot. Construction-time refusal of structural mistakes lives here. |
91
- | `src/catalog-service.ts` | Tool listing: cold-load coalescing, TTL, persistence as manifest plus revision-addressed chunks, stale fallback, and the completeness rule a partial catalog is a failure, never a cache write. |
93
+ | `src/registry.ts` | The connector set, address resolution, catalog TTL/persistence/completeness, shared refresh single-flight, connector health, per-connector call limiters, and drift. Construction-time refusals live here. |
94
+ | `src/catalog-service.ts` | Request-local tool listing, search, and describe. It coalesces reads inside one request and opts agent reads into the runtime's deferred catalog channel when one exists. |
92
95
  | `src/invocation.ts` | One tool call: argument validation, call admission, per-attempt timeout, retry with the connector's own `Retry-After` honoured exactly or declined, result unwrapping, size capping, and the activity record. |
93
96
  | `src/catalog.ts` | Ranking, description summarizing, and the compact schema renderer discovery shows. |
94
97
 
@@ -137,7 +140,7 @@ src/
137
140
  apps-shell.ts the one build-time MCP Apps template
138
141
  skills.ts MCP instructions, the usage skill, connector guides
139
142
  registry.ts connector set, addresses, health, call limiters
140
- catalog-service.ts catalog loading, caching, persistence, stale fallback
143
+ catalog-service.ts request-local catalog access, search, and describe
141
144
  catalog.ts ranking, summaries, compact schema rendering
142
145
  invocation.ts one tool call, end to end
143
146
  catalog-drift.ts vetted manifests and the counts a refresh produces
@@ -53,8 +53,8 @@ createConnecta({
53
53
  });
54
54
  ```
55
55
 
56
- Dynamic Workers require the Workers Paid plan. The complete required binding and
57
- package setup is in the [Worker example](../examples/worker/README.md#code-mode).
56
+ Dynamic Workers require the Workers Paid plan. The supported constructor passes only `loader`; `bindings`, `modules`, or
57
+ `globalOutbound` grant ambient guest authority and violate `P2`. The [Worker example](../examples/worker/README.md#code-mode) carries the full setup.
58
58
 
59
59
  ## What an executor must implement
60
60
 
@@ -104,8 +104,8 @@ Connecta passes exactly one provider, named `connecta`. An executor must:
104
104
  uncaught tool failure keeps its type (`E1`).
105
105
  6. **Capture `console.log`, `console.warn`, and `console.error`** into `logs` in
106
106
  call order (`R5`), bounding what it retains.
107
- 7. **Bound the guest**: wall clock, memory, stack, and CPU (`L3`, `L5`), with no
108
- network, filesystem, environment, or import capability (`P2`).
107
+ 7. **Bound the guest**: wall clock, memory, stack, and CPU (`L3`, `L5`). Keep
108
+ ambient capabilities within the documented and tested `P2`/`X5` boundary.
109
109
  8. **Grant no ambient authority of its own.** Never back this with `eval` or
110
110
  `node:vm`: the sandbox is a containment layer on top of connecta's boundary,
111
111
  not a replacement for it, and every capability arrives through `fns`.
@@ -138,10 +138,10 @@ reinterpreted, so do not rely on it.
138
138
  It is host plumbing, callable but not contract: it takes a connector id and an
139
139
  unsanitized-or-sanitized tool name and can change shape without notice.
140
140
 
141
- Anything else a runtime happens to expose is outside the contract and must not
142
- be used, even where it exists. Neither executor grants network egress,
143
- filesystem access, credentials, or deployment configuration; what they leave
144
- lying around otherwise differs (`X5`).
141
+ Anything else a runtime happens to expose is outside the portable contract and
142
+ must not be used. QuickJS grants none of it. A loader-only Dynamic Worker denies
143
+ external egress and filesystem access and keeps its environment maps empty, but
144
+ it exposes the globals and runtime builtins described in `X5`.
145
145
 
146
146
  **P3.** Values cross the host bridge as JSON. Arguments must be
147
147
  JSON-serializable and results arrive as plain JSON values. A value outside JSON —
@@ -154,8 +154,9 @@ scratch storage carried to the next program, and no request-bound object outlive
154
154
  the request that created it. Within one execution, host calls share one
155
155
  downstream request scope.
156
156
 
157
- **P5.** Plain JavaScript only. TypeScript syntax is a syntax error, and there is
158
- no `import` or `require` to reach for.
157
+ **P5.** Plain JavaScript only. TypeScript syntax is a syntax error. Portable code
158
+ does not import: QuickJS blocks imports, while Dynamic Workers expose the `X5`
159
+ runtime modules. Neither executor exposes `require`.
159
160
 
160
161
  ## Addressing
161
162
 
@@ -240,8 +241,8 @@ can set `includeSchemaKeys: false` to buy the bytes back.
240
241
  **S3.** Discovery is bounded and the bounds throw rather than silently shrink: a
241
242
  `limit` outside 1–100 is `invalid_args`, and a page whose serialized form
242
243
  exceeds 256,000 bytes is `result_too_large`, each with a hint naming the ways to
243
- ask for less. As with every failure, the *thrown* error carries only the message
244
- (`E1`); the code appears when the failure escapes the program uncaught.
244
+ ask for less. The thrown error carries the stable `code`, `retryable`, and
245
+ `details` fields (`E1`).
245
246
 
246
247
  ### connecta.describe
247
248
 
@@ -295,9 +296,7 @@ order. A success is `{ address, ok: true, data }`. A failure is
295
296
  field names the host's internal batch path uses. One failing call never rejects
296
297
  the batch, and more than ten calls throws.
297
298
 
298
- **S8.** `connecta.batch` is the classification channel: because a thrown host
299
- error crosses the bridge as a bare message (`E1`), a batch of one is the supported
300
- way for a program to *decide* something about a failure rather than report it.
299
+ **S8.** Batch and thrown failures share one vocabulary (`E1`): an entry's `errorDetails.code` and `retryable` equal the fields on the error the same call would throw. Use batch for independent concurrency, not to recover lost type.
301
300
 
302
301
  ### connecta.emit
303
302
 
@@ -310,24 +309,18 @@ clauses are [Emitted output](#emitted-output) (`M1`–`M10`).
310
309
 
311
310
  ## Errors
312
311
 
313
- **E1.** There are four error channels, and only two of them are typed.
312
+ **E1.** There are four error channels. Connecta failures are typed whether caught or uncaught.
314
313
 
315
314
  | Channel | Shape | Typed? |
316
315
  | --- | --- | --- |
317
- | A throw inside the program | `Error` with `message` only | no |
316
+ | A caught Connecta host failure | `Error` with `message`, `code`, `retryable`, and `details` | yes |
318
317
  | `connecta.batch` outcome | `{ ok: false, error, errorDetails }` | yes |
319
318
  | An uncaught **tool or discovery** failure, as the model sees it | `{ error: { code, message, retryable, … } }` with `isError` | yes |
320
- | Anything else that ends the run (`E5`, `E6`, a bridge bound in `L6`) | error text | no |
321
-
322
- The message-only throw is a hard limit of the guest bridge: both executors reduce a rejected
323
- host call to `new Error(message)`, dropping every own property. A program must
324
- therefore never branch on an error's fields and never parse its message. To
325
- classify, use `errorDetails`; to hand a failure to the model with its type
326
- intact, let it escape uncaught — connecta re-attaches the typed details on the
327
- way out. The model-facing version of this lives in `execute_code`'s description,
328
- not in the always-loaded usage skill, which `test/meta-tools.test.ts` caps at
329
- 2,500 bytes — a budget the guide already spends nearly all of, so new text there
330
- displaces old rather than adding to what every request pays for.
319
+ | Program or execution failure (`E5`, `E6`, a bridge bound in `L6`) | error text | no |
320
+
321
+ Both executor bridges reduce a rejected host call to `new Error(message)`. Connecta restores the typed failure in a trusted prelude with a per-execution authenticated frame (`X11`), without turning the rejection into a returned value.
322
+ `message` remains the human text. `code` and `retryable` are the stable branch fields; `details` is the complete host classification. This covers `call`, connector shortcuts, `search`, `describe`, `emit`, `ui`, rejected batch input, and the host-call budget.
323
+ Program-authored errors stay untyped, and code must never parse error prose.
331
324
 
332
325
  **E2.** The taxonomy: `retryable` is what connecta reports, `Y3` what a program may do.
333
326
 
@@ -339,16 +332,18 @@ displaces old rather than adding to what every request pays for.
339
332
  | `destructive_tool_requires_approval` | the tool is not explicitly read-only | false |
340
333
  | `auth_required` | the credential is missing, expired, or rejected | false |
341
334
  | `invalid_args` | arguments or discovery bounds were rejected | false |
342
- | `not_found` | the downstream answered and the resource is not there — the one code that says skip this id rather than stop, classified off `errorDetails` per `E1` and never off a caught error, raised only where the provider tells absence from a permission gap ([H11](./provider-conventions.md#h11--errors-are-mapped-to-what-the-caller-does-next)) | false |
335
+ | `not_found` | the downstream answered and the resource is not there — the one code that says skip this id rather than stop, raised only where the provider tells absence from a permission gap ([H11](./provider-conventions.md#h11--errors-are-mapped-to-what-the-caller-does-next)) | false |
343
336
  | `input_required_unsupported` | a downstream asked for mid-call input | false |
344
337
  | `rate_limited` | the downstream reported a rate limit | true |
345
338
  | `unavailable` | the downstream is down or unreachable | true |
346
339
  | `timeout` | the per-call 15-second deadline expired | true |
347
340
  | `cancelled` | the run ended while this call was in flight (`E5`) | false |
348
- | `connector_call_failed` | anything else the connector threw, and the host-call budget (`L4`) | per message |
341
+ | `connector_call_failed` | anything else the connector threw | per message |
349
342
  | `batch_call_failed` | a `connecta.batch` entry connecta could not even attempt | per message |
350
343
  | `catalog_lookup_failed` | the connector's catalog could not be loaded | per cause |
351
344
  | `result_processing_failed` | the result could not be prepared | per message |
345
+ | `result_too_large` | a discovery response exceeded its byte bound | false |
346
+ | `budget_exceeded` | the run exhausted a host-call or emitted-output budget | false |
352
347
 
353
348
  **E3.** `auth_required` carries the same recovery envelope as `call_tool`:
354
349
  `connector`, `operation`, `recovery` (`oauth`, `operator_config`, or
@@ -608,25 +603,22 @@ annotation-gated `maxRetries`; code mode fixes it at zero, so one
608
603
  `connecta.call` is exactly one downstream attempt. The program is the retry
609
604
  loop, and its budget is visible to it (`L4`).
610
605
 
611
- **Y2.** A program may retry a failure whose `errorDetails.retryable` is true,
612
- learned through `connecta.batch` (`S8`). Every attempt spends host-call budget,
613
- so a retry loop that ignores the budget converts a transient failure into a
614
- budget failure.
606
+ **Y2.** A program may retry a caught failure whose `retryable` is true, or a batch failure whose `errorDetails.retryable` is true (`S8`). Every attempt spends host-call budget, so an unchecked loop converts a transient failure into `budget_exceeded`.
615
607
 
616
608
  **Y3.** What must never be retried automatically:
617
609
 
618
610
  - anything with `retryable: false` — a policy refusal, a missing credential, a
619
611
  bad address, or malformed arguments will fail identically forever;
620
- - `rate_limited`, immediately. The sandbox has no timers, so a program cannot
621
- wait out a window; retrying inside it is the harm the signal exists to
622
- prevent. Return the failure and let the model, which can wait, re-issue with
623
- `retryAfterMs` in hand.
612
+ - `rate_limited`, immediately. A portable program has no timer, and a
613
+ Dynamic-Worker-only wait would spend the run's wall-clock budget on code that
614
+ fails on QuickJS. Return the failure and let the model, which can wait,
615
+ re-issue with `retryAfterMs` in hand.
624
616
  - a cancelled or timed-out *execution*: it is already over (`L1`).
625
617
 
626
618
  **Y4.** Connecta's own retry machinery beneath the meta-tools honours a
627
619
  connector-reported `Retry-After` exactly or not at all, and declines windows
628
620
  longer than 10 seconds rather than shortening them. A program sees the window
629
- verbatim as `errorDetails.retryAfterMs`.
621
+ verbatim as `err.details.retryAfterMs` or `errorDetails.retryAfterMs`.
630
622
 
631
623
  ## Cancellation and limits
632
624
 
@@ -656,10 +648,7 @@ because connecta enforces them above the sandbox:
656
648
  | Result | 24,000 serialized characters |
657
649
  | Logs presented to the model | 4,000 characters |
658
650
 
659
- Exhausting the host-call budget fails that call like any other, with code
660
- `connector_call_failed` (`E2`) and a message naming the budget — no connector was
661
- reached, so nothing more specific is true. Retrying it is pointless: the budget
662
- does not refill inside one execution.
651
+ Exhausting the host-call budget fails that call with non-retryable `budget_exceeded` (`E2`) and a message naming the budget. No connector is reached, and the budget does not refill inside one execution.
663
652
 
664
653
  **L5.** The guest is memory-, stack-, and CPU-bounded, and a program that
665
654
  exhausts a bound ends the run with an error instead of degrading the host. The
@@ -672,7 +661,7 @@ code safe to run at all.
672
661
  **L6.** A host call's serialized arguments and its serialized result are each
673
662
  bounded — QuickJS caps both at 256 KiB (`X10`) — and exceeding either fails that
674
663
  call, not the execution, so a program can catch it and ask for less. The failure
675
- is untyped text (`E1`). An over-bound *result* names the address the program
664
+ is executor-owned untyped text, not a Connecta host failure (`E1`). An over-bound *result* names the address the program
676
665
  called, not the internal dispatcher behind the shortcut namespaces; an over-bound
677
666
  *argument* payload is refused before it is parsed, so it names no address at
678
667
  all — parsing it to write a better message would spend exactly the work the bound
@@ -750,11 +739,9 @@ Worker renders arguments with `String()` (so an object logs as
750
739
  latter two. Only the three captured everywhere are contract (`R5`); rendering is
751
740
  not.
752
741
 
753
- **X5. Leftover globals.** The QuickJS guest has no `fetch`, `process`, timers,
754
- `crypto`, or `WebSocket` at all. The Dynamic Worker guest has all of them:
755
- `fetch` exists but throws on use because outbound access is disabled,
756
- `process.env` is empty, and timers work. `P2` is the contract — a program that
757
- uses `setTimeout` is writing Workers-only code, and it will fail on Node.
742
+ **X5. Leftover authority.** QuickJS blocks imports and has no `fetch`, `process`, timers, `crypto`, or `WebSocket`. A Dynamic Worker has those globals plus a non-contract set of runtime builtins through `import()` and `process.getBuiltinModule()`, including `node:path`, `node:crypto`, `node:net`, `node:tls`, `node:dns`, `node:module`, and `cloudflare:workers`. The upstream set can drift; this list is not an allowlist.
743
+ The supported Worker construction is exactly `new DynamicWorkerExecutor({ loader })`. Do not pass `bindings`, `modules`, or `globalOutbound`: each can grant ambient configuration, code, or egress. Under it, `process.env`, lexical `this.env`, and `cloudflare:workers.env` are empty; `node:fs`, `node:http`, and `node:https` are unavailable through either access route; external `fetch`, `WebSocket`, `node:net`, and `node:tls` fail with workerd's outbound-denial error; DNS lookup ends unresolved; and `fetch("data:...")` resolves locally.
744
+ `P2` is the portable contract. Programs use none of this runtime-only authority, including timers and `crypto`, because the same code fails on QuickJS. The `execute_code` description and served `usage` skill say so before an agent writes code.
758
745
 
759
746
  **X6. Stall detection.** QuickJS notices a program awaiting something that can
760
747
  never settle and fails fast; the Dynamic Worker waits for its deadline. The fast
@@ -786,11 +773,16 @@ a `process.send` with a hard ceiling. A program that returns a quarter-megabyte
786
773
  from one tool call therefore fails on Node and may succeed on Workers — reduce
787
774
  inside the program either way (`R1`).
788
775
 
776
+ **X11. Typed host rejection.** Both executors rebuild Connecta's authenticated host-failure frame as a thrown guest `Error` (`E1`). The per-run secret stays in the trusted prelude closure, and the prelude locks `globalThis.Error`, so guest code and connector prose cannot forge the host transport frame.
777
+ The human message is unchanged; a mismatched frame is ordinary untyped prose.
778
+
789
779
  ## Changes from earlier code mode
790
780
 
791
- Five behaviors changed with this contract, matching the changelog's Unreleased
781
+ Six behaviors changed with this contract, matching the changelog's Unreleased
792
782
  entry. Programs that ran before still run.
793
783
 
784
+ - **Caught Connecta failures expose their classification** (`E1`, `X11`). Their human message and thrown semantics stay unchanged; `code`, `retryable`, and `details` are additive.
785
+
794
786
  - **`connecta.batch` failures gained `errorDetails`** (`S7`). They carried only a
795
787
  message, which left a program unable to tell a policy refusal from a transient
796
788
  failure. Additive, and it reuses the host's internal batch field names, so a
@@ -833,7 +825,7 @@ the upstream `Executor` shape assignable.
833
825
  | Clauses | Test |
834
826
  | --- | --- |
835
827
  | `P1`, `P5` | `test/guest-api-contract.test.ts` (TypeScript syntax), `test/quickjs-executor.test.ts` (`normalizeCode`) |
836
- | `P2`, `X5` | `test/guest-api-contract.test.ts` (no usable network, no config) |
828
+ | `P2`, `X5` | `test/guest-api-contract.test.ts` (Dynamic globals plus loader-only filesystem, HTTP, environment, egress, DNS, and local `data:` boundaries), `test/guest-api-contract-quickjs.test.ts` (exact absent globals and blocked imports), `test/deployment-shapes.test.ts` (loader-only Worker construction) |
837
829
  | `P3`, `X9` | `test/guest-api-contract.test.ts`, `test/execute.test.ts` |
838
830
  | `P4` | `test/guest-api-contract.test.ts` (no cross-run leakage), `test/execute.test.ts` (one catalog load per connector per execution) |
839
831
  | `A1`, `A2` | `test/guest-api-contract.test.ts`, `test/execute.test.ts` (sanitizing) |
@@ -846,8 +838,8 @@ the upstream `Executor` shape assignable.
846
838
  | `S5` | `test/guest-api-contract.test.ts`, `test/execute.test.ts` (`unwrapMcpResult`) |
847
839
  | `S6` | `test/execute.test.ts` (fail-closed annotations, activity parity) |
848
840
  | `S7` | `test/guest-api-contract.test.ts`, `test/execute.test.ts` (batch cap) |
849
- | `S8`, `E1` | `test/guest-api-contract.test.ts` (typed batch outcomes) |
850
- | `E2`, `E8` | `test/guest-api-contract.test.ts` (code → `retryable`, batch and uncaught validation recovery), `test/meta-tools.test.ts` (direct, destructive, batch, provider fallback), `test/validate.test.ts` (bounded payload-free findings), `test/errors.test.ts` |
841
+ | `S8`, `E1`, `X11` | both guest-contract executors (caught call, namespace, discovery, utility, batch-validation, budget, and forgery cases; typed batch equivalence) |
842
+ | `E2`, `E8` | `test/guest-api-contract.test.ts` (code → `retryable`, caught, batch, and uncaught validation recovery), `test/meta-tools.test.ts` (direct, destructive, batch, provider fallback), `test/validate.test.ts` (bounded payload-free findings), `test/errors.test.ts` |
851
843
  | `E3` | `test/guest-api-contract.test.ts`, `test/execute.test.ts` (`auth_required`) |
852
844
  | `E4` | `test/guest-api-contract.test.ts`, `test/execute.test.ts` (destructive) |
853
845
  | `E5` | `test/guest-api-contract.test.ts` (execution-failure channel, in-flight `cancelled`), `test/execute.test.ts` (admission), `test/executor-admission.test.ts`, `test/quickjs-executor.test.ts` (mid-run shutdown) |