@leadbay/mcp 0.24.1 → 0.25.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.
@@ -568,7 +568,7 @@ Build the final mappings yourself. Start from \`leadbay_resolve_import_rows.mapp
568
568
 
569
569
  # PHASE 5 \u2014 QUALIFY (optional) + REPORT
570
570
 
571
- Prefer \`leadbay_import_and_qualify\` when the user asks to qualify/research after import; otherwise use \`leadbay_import_leads\`. For large files or short client timeouts, pass \`wait_for_completion=false\` and poll \`leadbay_import_status\`. After import, qualify only lead IDs returned by the import; late website matches may appear later via \`import_status\`.
571
+ Prefer \`leadbay_import_and_qualify\` when the user asks to qualify/research after import; otherwise use \`leadbay_import_leads\`. For large files or short client timeouts, pass \`wait_for_completion=false\` and poll \`leadbay_import_status\`. After import, qualify only lead IDs returned by the import. Rows that came back \`uncrawled\` are pending a background crawl (not failures); the leads Leadbay adds for them populate in the user's Leadbay account as the crawl completes \u2014 tell the user that, not that a tool call will fetch them (\`import_status\` refreshes status/progress only; \`pull_leads\` reads the active lens, so an imported lead outside it may not appear; re-running the import later re-reconciles those companies).
572
572
 
573
573
  **Deliver the augmented file back to the user**: the original file plus a new \`LEADBAY_ID\` column populated from the resolution step. This is the second deliverable of a job well done.
574
574
 
@@ -1731,6 +1731,34 @@ var LeadbayClient = class {
1731
1731
  defaultLensCachedAt = null;
1732
1732
  mePayload = null;
1733
1733
  mePayloadCachedAt = null;
1734
+ // Monotonic sequence bumped whenever the telemetry preference is decided by a
1735
+ // fresher signal — an explicit stamp (setCachedTelemetryEnabled) or the START
1736
+ // of a telemetry read (resolveMe / fetchTelemetryEnabled). A read snapshots it
1737
+ // and only writes telemetryEnabledCache if the sequence is UNCHANGED when it
1738
+ // completes, so (a) a stamp landing mid-read wins over the stale read and (b)
1739
+ // an older overlapping read that resolves last can't clobber a newer read's
1740
+ // value (product#3879, Codex P1).
1741
+ telemetryStateSeq = 0;
1742
+ // The telemetry preference lives in its OWN field, separate from mePayload,
1743
+ // so it survives invalidateMe() (Codex P1). Otherwise a leadbay_set_telemetry
1744
+ // disable would be forgotten the moment the very next same-session tool
1745
+ // invalidates the /me cache (refine_prompt, my_lenses, set_active_lens, …),
1746
+ // dropping cachedTelemetryEnabled() back to undefined and letting the hosted
1747
+ // suppression predicate fall through to a stale "enabled". undefined = never
1748
+ // observed; the last read/stamp always wins and persists across /me churn.
1749
+ telemetryEnabledCache = void 0;
1750
+ // True when telemetryEnabledCache came from an EXPLICIT user stamp
1751
+ // (leadbay_set_telemetry via setCachedTelemetryEnabled), as opposed to a
1752
+ // /users/me read. A stamp is the user's direct choice for THIS request and is
1753
+ // the single most authoritative signal — it outranks even a fail-closed
1754
+ // verdict from a timed-out/errored read, so a same-request opt-IN takes effect
1755
+ // even when a background refresh just failed closed (Codex P2). Reset to false
1756
+ // whenever a read writes the cache or the tenant switches.
1757
+ telemetryEnabledFromStamp = false;
1758
+ // Counts explicit user stamps only. Unlike telemetryStateSeq, read-starts do
1759
+ // not move it, so callers can distinguish "a same-message stamp happened" from
1760
+ // "a background refresh merely started" when demoting stale opt-in stamps.
1761
+ telemetryStampStateSeq = 0;
1734
1762
  tasteProfile = null;
1735
1763
  tasteProfileCachedAt = null;
1736
1764
  // Simple semaphore for concurrency limiting.
@@ -1766,20 +1794,28 @@ var LeadbayClient = class {
1766
1794
  get lastMeta() {
1767
1795
  return this._lastMeta;
1768
1796
  }
1769
- // Used by login when region auto-detect picks a different backend than the
1770
- // one the client was constructed with.
1771
- setBaseUrl(baseUrl, region) {
1772
- this._baseUrl = baseUrl.replace(/\/+$/, "");
1773
- this._region = region ?? (baseUrl === REGIONS.us ? "us" : baseUrl === REGIONS.fr ? "fr" : "custom");
1797
+ clearTenantScopedCaches() {
1774
1798
  this.defaultLensId = null;
1775
1799
  this.defaultLensCachedAt = null;
1776
1800
  this.mePayload = null;
1777
1801
  this.mePayloadCachedAt = null;
1778
1802
  this.tasteProfile = null;
1779
1803
  this.tasteProfileCachedAt = null;
1804
+ this.telemetryEnabledCache = void 0;
1805
+ this.telemetryEnabledFromStamp = false;
1806
+ this.telemetryStateSeq++;
1807
+ this.telemetryStampStateSeq++;
1808
+ }
1809
+ // Used by login when region auto-detect picks a different backend than the
1810
+ // one the client was constructed with.
1811
+ setBaseUrl(baseUrl, region) {
1812
+ this._baseUrl = baseUrl.replace(/\/+$/, "");
1813
+ this._region = region ?? (baseUrl === REGIONS.us ? "us" : baseUrl === REGIONS.fr ? "fr" : "custom");
1814
+ this.clearTenantScopedCaches();
1780
1815
  }
1781
1816
  setToken(token) {
1782
1817
  this.token = token;
1818
+ this.clearTenantScopedCaches();
1783
1819
  }
1784
1820
  get isAuthenticated() {
1785
1821
  return this.token !== null;
@@ -2060,17 +2096,139 @@ var LeadbayClient = class {
2060
2096
  if (!force && this.mePayload !== null && this.mePayloadCachedAt !== null && now - this.mePayloadCachedAt < ME_CACHE_TTL_MS) {
2061
2097
  return this.mePayload;
2062
2098
  }
2099
+ const seqAtStart = ++this.telemetryStateSeq;
2063
2100
  const me = await this.request("GET", "/users/me");
2064
2101
  this.mePayload = me;
2065
2102
  this.mePayloadCachedAt = now;
2103
+ if (this.telemetryStateSeq === seqAtStart && me.telemetry_enabled !== void 0) {
2104
+ this.telemetryEnabledCache = me.telemetry_enabled;
2105
+ this.telemetryEnabledFromStamp = false;
2106
+ }
2066
2107
  return me;
2067
2108
  }
2109
+ // Lightweight cross-session telemetry-preference read for the hosted SSE
2110
+ // per-message refresh (product#3879, Codex P2). UNLIKE resolveMe() this does
2111
+ // NOT touch mePayload / the general /me cache — so a slow background refresh
2112
+ // can never repopulate a stale last_requested_lens over a tool's mutation, and
2113
+ // it never serves the 60s /me cache (always a fresh read). It reads the SAME
2114
+ // /users/me endpoint (telemetry_enabled lives there) but only reconciles the
2115
+ // dedicated telemetry field, under the same sequence guard as resolveMe.
2116
+ //
2117
+ // It deliberately bypasses request() and therefore never writes _lastMeta
2118
+ // (Codex P2): the refresh shares the tool's client, and request() rewrites
2119
+ // _lastMeta on every call. Without isolation, a refresh completing between a
2120
+ // tool's real backend call and that tool copying client.lastMeta into its
2121
+ // result (e.g. pull-leads' _meta.latency_ms) could make the metadata describe
2122
+ // GET /users/me instead of the tool call.
2123
+ //
2124
+ // Returns the observed preference: true/false, or undefined when the backend
2125
+ // omitted the field (older backend → caller treats as enabled default).
2126
+ async fetchTelemetryEnabled() {
2127
+ const seqAtStart = ++this.telemetryStateSeq;
2128
+ if (process.env.LEADBAY_MOCK === "1") {
2129
+ const metaBefore = this._lastMeta;
2130
+ try {
2131
+ const me = this.mockRequest("GET", "/users/me");
2132
+ const observed = me.telemetry_enabled;
2133
+ if (this.telemetryStateSeq === seqAtStart && observed !== void 0) {
2134
+ this.telemetryEnabledCache = observed;
2135
+ this.telemetryEnabledFromStamp = false;
2136
+ }
2137
+ return observed;
2138
+ } finally {
2139
+ this._lastMeta = metaBefore;
2140
+ }
2141
+ }
2142
+ if (!this.token) {
2143
+ throw this.makeError("NOT_AUTHENTICATED", "Not logged in to Leadbay", "Set LEADBAY_TOKEN in your MCP client config, or run: npx -y -p @leadbay/mcp@latest installer", "/users/me");
2144
+ }
2145
+ await this.acquireSemaphore();
2146
+ try {
2147
+ const res = await this.httpsRequestWithRetry("GET", `${this._baseUrl}${API_PREFIX}/users/me`, { Authorization: `Bearer ${this.token}` }, void 0);
2148
+ if (res.status < 200 || res.status >= 300) {
2149
+ throw this.mapErrorResponse(res.status, res.body, "/users/me", res.headers);
2150
+ }
2151
+ const me = JSON.parse(res.body);
2152
+ const observed = me.telemetry_enabled;
2153
+ if (this.telemetryStateSeq === seqAtStart && observed !== void 0) {
2154
+ this.telemetryEnabledCache = observed;
2155
+ this.telemetryEnabledFromStamp = false;
2156
+ }
2157
+ return observed;
2158
+ } finally {
2159
+ this.releaseSemaphore();
2160
+ }
2161
+ }
2068
2162
  // Force re-fetch on next resolveMe(). Call from any tool that mutates a
2069
- // /me-cached field (last_requested_lens, billing, etc.).
2163
+ // /me-cached field (last_requested_lens, billing, etc.). Deliberately does
2164
+ // NOT clear telemetryEnabledCache — the opt-out preference is orthogonal to
2165
+ // /me staleness and must survive invalidation (Codex P1).
2070
2166
  invalidateMe() {
2071
2167
  this.mePayload = null;
2072
2168
  this.mePayloadCachedAt = null;
2073
2169
  }
2170
+ // Synchronous read of the last-cached telemetry preference, without a fetch.
2171
+ // Returns undefined when /users/me hasn't been resolved (or was invalidated).
2172
+ // The hosted telemetry suppression predicate reads this AT CAPTURE TIME so a
2173
+ // leadbay_set_telemetry disable within the same request suppresses that very
2174
+ // request's tracking — the opt-out action isn't itself the last tracked event
2175
+ // (product#3879). resolveMe() keeps mePayload populated after a write, so this
2176
+ // reflects the post-write state.
2177
+ cachedTelemetryEnabled() {
2178
+ return this.telemetryEnabledCache;
2179
+ }
2180
+ // True when the cached preference came from an explicit user stamp (a
2181
+ // leadbay_set_telemetry toggle), not a read. The hosted suppression predicate
2182
+ // treats a stamp as the single most-authoritative signal — it outranks a
2183
+ // fail-closed verdict from a failed background read, so a same-request opt-IN
2184
+ // takes effect even when a refresh just timed out (product#3879, Codex P2).
2185
+ cachedTelemetryStamped() {
2186
+ return this.telemetryEnabledFromStamp && this.telemetryEnabledCache !== void 0;
2187
+ }
2188
+ // Monotonic sequence exposed so callers can tell whether a telemetry stamp
2189
+ // happened AFTER a reference point (e.g. an SSE message start). Bumped by every
2190
+ // stamp and every telemetry read-start; see telemetryStateSeq.
2191
+ telemetrySeq() {
2192
+ return this.telemetryStateSeq;
2193
+ }
2194
+ // Monotonic sequence moved only by explicit user stamps. Used by the SSE
2195
+ // refresh failure path to demote stale opt-in stamps without mistaking a
2196
+ // read-start sequence bump for a same-message opt-in.
2197
+ telemetryStampSeq() {
2198
+ return this.telemetryStampStateSeq;
2199
+ }
2200
+ // Demote the cached preference from "explicit stamp" to ordinary read-level
2201
+ // authority WITHOUT changing its value. A stamp is scoped to the request that
2202
+ // made it (Codex P2): once a LATER SSE message's refresh produces a
2203
+ // fail-closed verdict (timeout/error), that earlier stamp must no longer
2204
+ // outrank it, or a session that once enabled would keep emitting through every
2205
+ // subsequent unreadable refresh.
2206
+ //
2207
+ // `onlyIfStampSeqAtMost` guards against demoting a stamp made by the CURRENT
2208
+ // message (Codex P2): pass the STAMP sequence captured at message start; if a
2209
+ // stamp has bumped it beyond the snapshot, that stamp is same-message (a fresh
2210
+ // opt-in) and must be preserved. Read-starts do not affect this guard.
2211
+ clearTelemetryStampOrigin(onlyIfStampSeqAtMost) {
2212
+ if (onlyIfStampSeqAtMost !== void 0 && this.telemetryStampStateSeq > onlyIfStampSeqAtMost) {
2213
+ return;
2214
+ }
2215
+ this.telemetryEnabledFromStamp = false;
2216
+ }
2217
+ // Deterministically stamp the cached telemetry preference to a known value,
2218
+ // WITHOUT a fetch. leadbay_set_telemetry calls this right after a successful
2219
+ // POST /users/telemetry so the suppression predicate reflects the new state
2220
+ // even if the follow-up refresh fails (product#3879) — a disable must never
2221
+ // fail open and let the opt-out request emit error telemetry. Creates a
2222
+ // minimal cache entry if /users/me was never resolved.
2223
+ setCachedTelemetryEnabled(enabled) {
2224
+ this.telemetryStateSeq++;
2225
+ this.telemetryStampStateSeq++;
2226
+ this.telemetryEnabledCache = enabled;
2227
+ this.telemetryEnabledFromStamp = true;
2228
+ if (this.mePayload) {
2229
+ this.mePayload = { ...this.mePayload, telemetry_enabled: enabled };
2230
+ }
2231
+ }
2074
2232
  async resolveDefaultLens() {
2075
2233
  const now = Date.now();
2076
2234
  if (this.defaultLensId !== null && this.defaultLensCachedAt !== null && now - this.defaultLensCachedAt < LENS_CACHE_TTL_MS) {
@@ -8181,6 +8339,8 @@ WHEN NOT TO USE: discovery (use leadbay_pull_leads); single-lead deep dive (use
8181
8339
 
8182
8340
  Budgets: \`total_budget_ms\` caps wall-clock; \`per_lead_budget_ms\` caps each lead's poll. For short transport timeouts, pass \`wait_for_completion:false\` and poll \`leadbay_import_status\`. Outputs \`qualified[]\`, \`still_running[]\`, \`not_imported[]\`, \`qualify_id\` (resumable handle). Idempotent within a 5-min window. \`dry_run:'preview'\` returns mapping hints + custom-field candidates without importing.
8183
8341
 
8342
+ \`not_imported\` rows with \`reason:"uncrawled"\` are **pending a background crawl**, NOT failures: Leadbay just hasn't matched/crawled that domain yet and will add the lead asynchronously (the label doesn't verify the URL resolves \u2014 don't call the site bad, but don't certify it valid either). Surface them as pending; the leads populate in the user's Leadbay account as the crawl completes (no tool here fetches them on demand \u2014 \`leadbay_import_status\` returns status/progress only, and \`leadbay_pull_leads\` reads the active lens's wishlist so an imported lead outside that lens may not appear). To pull those specific companies back through the MCP, re-run the import later. A large \`uncrawled\` share on a fresh list is normal.
8343
+
8184
8344
  This tool MUTATES state. The caller (agent or human-in-the-loop) is responsible for confirming intent before invocation; the MCP server does not soft-prompt for confirmation. See \`annotations.destructiveHint\`.
8185
8345
 
8186
8346
 
@@ -8192,18 +8352,42 @@ Requires: LEADBAY_MCP_WRITE=1 (MCP) or exposeWrite=true (OpenClaw); admin role;
8192
8352
 
8193
8353
  The response carries either a completed result or an async handle. Render a brief summary; do NOT enumerate every imported lead.
8194
8354
 
8355
+ **Dry run first:** if the result has \`dry_run:true\` (or ANY \`not_imported\` row has \`reason: "dry_run"\`), this was a VALIDATION pass \u2014 nothing was committed. Render \`"\u{1F50E} Dry run \u2014 V rows validated OK, nothing imported yet. Re-run without dry_run to commit."\` where V = the count of \`dry_run\` rows. If malformed rows are ALSO present (\`reason: "malformed"\`), list those separately as \`"\u26A0 M rows can't be imported as-is: <row \xB7 malformed>"\` so the validation count is never swallowed. Do NOT use the pending-crawl/need-attention bucket header below for a dry run (those buckets are for a real committed import).
8356
+
8357
+ Otherwise, partition \`not_imported\` by \`reason\` into these buckets before you write the header:
8358
+
8359
+ - **Pending crawl** \u2014 \`reason: "uncrawled"\` **AND the row has a \`domain\`**: Leadbay just hasn't crawled that domain yet and will add the lead asynchronously. These are NOT failures. (The label doesn't verify the URL resolves \u2014 don't claim the site is bad, but don't certify it's valid either. See the note below.)
8360
+ - **Need attention** \u2014 everything else that didn't import:
8361
+ - \`reason: "uncrawled"\` but the row has **no \`domain\`** (name/CRM-id-only row): there is nothing for Leadbay to crawl, so it will NOT self-resolve \u2014 count these under need-attention, not pending crawl, and tell the user to supply a company website/identity and re-import.
8362
+ - \`reason\` \u2208 \`malformed\` / \`internal_error\` / \`no_match\` / \`ambiguous\`: genuinely un-actionable or needs a follow-up call.
8363
+
8195
8364
  **Header \u2014 single line, choose by status:**
8196
8365
 
8197
- - Completed: \`"\u2713 Import complete \u2014 N leads imported \xB7 M failed \xB7 P resolved-with-ambiguity"\`
8366
+ - Completed: \`"\u2713 Import complete \u2014 N imported \xB7 P pending crawl \xB7 Q need attention"\` (drop any segment whose count is 0)
8198
8367
  - Running: \`"\u23F3 Import running \u2014 handle_id <id>; poll leadbay_import_status"\`
8199
8368
  - Pending qualification (\`leadbay_import_and_qualify\`): \`"\u2713 Imported N leads \xB7 qualifying M of them \u2014 qualify_id <id>"\`
8200
8369
 
8201
- **When failures or ambiguous rows are non-empty**, follow the header with a small bulleted list (\u2264 5 items): \`<row identifier or domain> \xB7 <reason>\`. Then \`"*+N more \u2014 leadbay_import_status for full detail*"\`.
8370
+ Count \`uncrawled\` rows as **pending**, never as failures \u2014 never say "M failed" when the M is mostly/entirely uncrawled rows.
8371
+
8372
+ **When the "need attention" or pending-crawl rows are non-empty**, follow the header with a small bulleted list (\u2264 5 items): \`<row identifier or domain> \xB7 <reason>\`. Label each row by its real reason \u2014 "pending crawl" for \`uncrawled\`, and the specific reason otherwise. Frame pending rows reassuringly (Leadbay is crawling them; the leads it adds will populate in the user's Leadbay account as the crawl completes \u2014 see the semantics note below for where they show up), not as errors. The full \`not_imported\` breakdown is already in THIS response \u2014 list from it directly; then \`"*+N more (see the full not_imported list in the response)*"\`.
8202
8373
 
8203
8374
  **When the user's request implied a downstream use** ("import then prep outreach for them"), emit \`Imported leadIds: <up to 5 ids, then '+N more'>\` \u2014 just the ids. Let the next composite render the leads.
8204
8375
 
8205
8376
  Defer the full list of imported leads to \`leadbay_pull_leads\` or \`leadbay_research_lead_by_id\` in NEXT STEPS.
8206
8377
 
8378
+ **\`uncrawled\` is NOT a failed import \u2014 it means "pending a crawl".** A row lands \`uncrawled\` when Leadbay hasn't matched or crawled that domain **yet** \u2014 the row simply didn't match an existing lead at import time and isn't a public-mailbox domain. It does NOT mean the import failed, and it is NOT a verdict that the website is broken (the tool doesn't check whether the URL resolves \u2014 so don't claim the site is bad, but don't guarantee it's valid either).
8379
+
8380
+ **One caveat \u2014 \`uncrawled\` only means "pending" when the row actually had a website.** A row imported by name / CRM id / registry number only (no \`LEAD_WEBSITE\` mapped) that finds no existing match ALSO lands \`uncrawled\`, but there's no domain for Leadbay to crawl \u2014 so it will NOT self-resolve via a late crawl. For those name-only rows, don't give the "Leadbay is crawling it" reassurance; tell the user to supply a company website (or another resolvable identity) and re-import. So: \`uncrawled\` + a website \u2192 genuinely pending a background crawl; \`uncrawled\` + no website \u2192 the user needs to add an identity, it won't crawl on its own. The import itself completed successfully; Leadbay then crawls the domain in the background and adds the lead asynchronously (a *late import*), so most of these rows resolve on their own within minutes to hours. Where do those late-added leads show up? **In the user's Leadbay account as the crawl completes.** \`leadbay_import_status\` does NOT return them \u2014 it only refreshes status/progress. There's no bulk "list the leads this import just added" call: \`leadbay_pull_leads\` reads the active lens's wishlist, so an imported lead not admitted to that lens won't appear there. For **one specific company by name**, \`leadbay_research_lead_by_name_fuzzy\` searches across the visible Leadbay corpus (not lens-scoped) and can surface it once crawled \u2014 a reasonable check for a named company. Otherwise tell the user the leads will populate in Leadbay over the next minutes\u2013hours; to pull those specific companies back through the MCP in bulk, **re-run the same import later** (the now-crawled domains match). Do NOT promise \`leadbay_pull_leads\` or \`import_status\` will list the late additions.
8381
+
8382
+ So when reporting an import: count \`uncrawled\` rows as **pending**, never as failures. Do NOT tell the user these rows "failed", were "rejected", had "bad/unreachable websites", or point to a backend problem \u2014 that is wrong and needlessly erodes trust in the whole lead set. A high \`uncrawled\` share on a fresh list is normal and expected, not a red flag.
8383
+
8384
+ How the OTHER reasons map to the "Need attention" bucket (see the render block above) \u2014 none of these should be lumped in with \`uncrawled\`/pending, but each is still surfaced to the user, not suppressed:
8385
+
8386
+ - \`malformed\` (row couldn't be parsed) and \`internal_error\` (a real backend error) are genuine failures \u2014 flag them plainly.
8387
+ - \`no_match\` on a public-mailbox domain (gmail.com, outlook.com, \u2026) means no company domain was resolvable from that row \u2014 surface it so the user can supply a real company domain. Not a crawler failure.
8388
+ - \`ambiguous\` rows matched several candidates \u2014 surface them as needing disambiguation via \`leadbay_resolve_import_rows\`. Not a failure, but the user still needs to act.
8389
+
8390
+
8207
8391
 
8208
8392
  ---
8209
8393
 
@@ -8231,8 +8415,9 @@ User picks \u2192 call the matching \`Calls\` tool. Constraints: 2\u20134 mutual
8231
8415
  |------------------------------------------------|---------------------------------------------------------------|--------------------------------------------------------|
8232
8416
  | Status: running | "Check progress" | leadbay_import_status(handle_id) |
8233
8417
  | Status: complete, imports succeeded | "Run AI qualification on the imported leads" | leadbay_bulk_qualify_leads([leadIds]) \u2014 or use leadbay_import_and_qualify next time |
8418
+ | Pending-crawl (\`uncrawled\`) rows present | "Re-run the import for those domains later, once Leadbay has crawled them" | leadbay_import_leads (re-run with just the uncrawled domains, later \u2014 they re-reconcile once crawled). NOTE: not a live-fetch of the added leads; those populate in the user's Leadbay account as the crawl completes |
8234
8419
  | Ambiguous / unresolved rows present | "Resolve the ambiguous rows" | leadbay_resolve_import_rows(records, identity_mappings)|
8235
- | Failed rows from bad mappings | "Check the org's mappable fields and remap" | leadbay_list_mappable_fields |
8420
+ | \`malformed\` / bad-mapping rows present | "Check the org's mappable fields and remap the bad rows" | leadbay_list_mappable_fields |
8236
8421
  | User wants to see the imported leads | "See the imported leads in your view" | leadbay_pull_leads |
8237
8422
  | User had follow-up intent for the imports | "Prep outreach for [a specific imported lead]" | leadbay_prepare_outreach(leadId) |
8238
8423
  `;
@@ -8240,6 +8425,8 @@ var leadbay_import_leads = `Import leads into Leadbay's CRM via the file-import
8240
8425
 
8241
8426
  TWO MODES: (A) Domain-list shortcut \u2014 pass \`domains: [{domain, name?}]\`. The tool builds a 2-column CSV (LEAD_NAME, LEAD_WEBSITE) and imports with the default mapping. (B) Custom records + mapping \u2014 pass \`records: [{Col1, Col2, ...}]\` plus \`mappings.fields: {Col1: 'LEAD_NAME', ...}\`. \`mappings.fields\` must include LEADBAY_ID, CRM_ID, SIREN, LEAD_NAME, or LEAD_WEBSITE (resolver needs at least one identity key). Pass exactly one of \`domains\` / \`records\`. Reserved column \`MCP_ROW_ID\` cannot appear in records/mappings \u2014 the tool injects it for stable reconciliation.
8242
8427
 
8428
+ \`not_imported\` rows with \`reason:"uncrawled"\` are **pending a background crawl**, NOT failures: Leadbay just hasn't matched/crawled that domain yet and will add the lead asynchronously (the label doesn't verify the URL resolves \u2014 don't call the site bad, but don't certify it valid either). Surface them as pending; the leads populate in the user's Leadbay account as the crawl completes (no tool here fetches them on demand \u2014 \`leadbay_import_status\` returns status/progress only, and \`leadbay_pull_leads\` reads the active lens's wishlist so an imported lead outside that lens may not appear). To pull those specific companies back through the MCP, re-run the import later. A large \`uncrawled\` share on a fresh list is normal.
8429
+
8243
8430
  MUTATES USER STATE: each call creates a row in the user's CRM-imports list (visible in the web UI) and touches onboarding state. Suitable for occasional automation, NOT for high-cadence (>5 calls/day). Imported leads are NOT auto-promoted to the user's Monitor view; lens-scoring threshold decides. For messy files call leadbay_resolve_import_rows first, then pass \`records_for_import\`/\`mappings_for_import\` here. Agents should inspect every column, build a preservation plan, and pass an explicit final mapping. For each meaningful column decide standard field, CONTACT_* field, Leadbay note, custom field, derived helper, or skip with a reason. For contact-only exports, derive a company-domain column from CONTACT_EMAIL only when it's a real business domain. Multiple rows can share the same LEADBAY_ID and import as separate contacts on that lead. Custom fields use \`CUSTOM.<id>\` in \`mappings.fields\` or the \`mappings.custom_fields\` shorthand. For source-system deep links create a custom field via leadbay_create_custom_field first (prefer EXTERNAL_ID + url_template). Preserve meaningful per-lead notes by calling leadbay_add_note after import returns lead IDs.
8244
8431
 
8245
8432
  WHEN TO USE: you have a list of company domains from another system (CRM, analytics, email correspondents) and need stable Leadbay leadIds; or CRM-shaped rows with custom columns and want to drive the wizard with explicit field mappings.
@@ -8257,18 +8444,42 @@ Requires: LEADBAY_MCP_WRITE=1 (MCP) or exposeWrite=true (OpenClaw); admin role o
8257
8444
 
8258
8445
  The response carries either a completed result or an async handle. Render a brief summary; do NOT enumerate every imported lead.
8259
8446
 
8447
+ **Dry run first:** if the result has \`dry_run:true\` (or ANY \`not_imported\` row has \`reason: "dry_run"\`), this was a VALIDATION pass \u2014 nothing was committed. Render \`"\u{1F50E} Dry run \u2014 V rows validated OK, nothing imported yet. Re-run without dry_run to commit."\` where V = the count of \`dry_run\` rows. If malformed rows are ALSO present (\`reason: "malformed"\`), list those separately as \`"\u26A0 M rows can't be imported as-is: <row \xB7 malformed>"\` so the validation count is never swallowed. Do NOT use the pending-crawl/need-attention bucket header below for a dry run (those buckets are for a real committed import).
8448
+
8449
+ Otherwise, partition \`not_imported\` by \`reason\` into these buckets before you write the header:
8450
+
8451
+ - **Pending crawl** \u2014 \`reason: "uncrawled"\` **AND the row has a \`domain\`**: Leadbay just hasn't crawled that domain yet and will add the lead asynchronously. These are NOT failures. (The label doesn't verify the URL resolves \u2014 don't claim the site is bad, but don't certify it's valid either. See the note below.)
8452
+ - **Need attention** \u2014 everything else that didn't import:
8453
+ - \`reason: "uncrawled"\` but the row has **no \`domain\`** (name/CRM-id-only row): there is nothing for Leadbay to crawl, so it will NOT self-resolve \u2014 count these under need-attention, not pending crawl, and tell the user to supply a company website/identity and re-import.
8454
+ - \`reason\` \u2208 \`malformed\` / \`internal_error\` / \`no_match\` / \`ambiguous\`: genuinely un-actionable or needs a follow-up call.
8455
+
8260
8456
  **Header \u2014 single line, choose by status:**
8261
8457
 
8262
- - Completed: \`"\u2713 Import complete \u2014 N leads imported \xB7 M failed \xB7 P resolved-with-ambiguity"\`
8458
+ - Completed: \`"\u2713 Import complete \u2014 N imported \xB7 P pending crawl \xB7 Q need attention"\` (drop any segment whose count is 0)
8263
8459
  - Running: \`"\u23F3 Import running \u2014 handle_id <id>; poll leadbay_import_status"\`
8264
8460
  - Pending qualification (\`leadbay_import_and_qualify\`): \`"\u2713 Imported N leads \xB7 qualifying M of them \u2014 qualify_id <id>"\`
8265
8461
 
8266
- **When failures or ambiguous rows are non-empty**, follow the header with a small bulleted list (\u2264 5 items): \`<row identifier or domain> \xB7 <reason>\`. Then \`"*+N more \u2014 leadbay_import_status for full detail*"\`.
8462
+ Count \`uncrawled\` rows as **pending**, never as failures \u2014 never say "M failed" when the M is mostly/entirely uncrawled rows.
8463
+
8464
+ **When the "need attention" or pending-crawl rows are non-empty**, follow the header with a small bulleted list (\u2264 5 items): \`<row identifier or domain> \xB7 <reason>\`. Label each row by its real reason \u2014 "pending crawl" for \`uncrawled\`, and the specific reason otherwise. Frame pending rows reassuringly (Leadbay is crawling them; the leads it adds will populate in the user's Leadbay account as the crawl completes \u2014 see the semantics note below for where they show up), not as errors. The full \`not_imported\` breakdown is already in THIS response \u2014 list from it directly; then \`"*+N more (see the full not_imported list in the response)*"\`.
8267
8465
 
8268
8466
  **When the user's request implied a downstream use** ("import then prep outreach for them"), emit \`Imported leadIds: <up to 5 ids, then '+N more'>\` \u2014 just the ids. Let the next composite render the leads.
8269
8467
 
8270
8468
  Defer the full list of imported leads to \`leadbay_pull_leads\` or \`leadbay_research_lead_by_id\` in NEXT STEPS.
8271
8469
 
8470
+ **\`uncrawled\` is NOT a failed import \u2014 it means "pending a crawl".** A row lands \`uncrawled\` when Leadbay hasn't matched or crawled that domain **yet** \u2014 the row simply didn't match an existing lead at import time and isn't a public-mailbox domain. It does NOT mean the import failed, and it is NOT a verdict that the website is broken (the tool doesn't check whether the URL resolves \u2014 so don't claim the site is bad, but don't guarantee it's valid either).
8471
+
8472
+ **One caveat \u2014 \`uncrawled\` only means "pending" when the row actually had a website.** A row imported by name / CRM id / registry number only (no \`LEAD_WEBSITE\` mapped) that finds no existing match ALSO lands \`uncrawled\`, but there's no domain for Leadbay to crawl \u2014 so it will NOT self-resolve via a late crawl. For those name-only rows, don't give the "Leadbay is crawling it" reassurance; tell the user to supply a company website (or another resolvable identity) and re-import. So: \`uncrawled\` + a website \u2192 genuinely pending a background crawl; \`uncrawled\` + no website \u2192 the user needs to add an identity, it won't crawl on its own. The import itself completed successfully; Leadbay then crawls the domain in the background and adds the lead asynchronously (a *late import*), so most of these rows resolve on their own within minutes to hours. Where do those late-added leads show up? **In the user's Leadbay account as the crawl completes.** \`leadbay_import_status\` does NOT return them \u2014 it only refreshes status/progress. There's no bulk "list the leads this import just added" call: \`leadbay_pull_leads\` reads the active lens's wishlist, so an imported lead not admitted to that lens won't appear there. For **one specific company by name**, \`leadbay_research_lead_by_name_fuzzy\` searches across the visible Leadbay corpus (not lens-scoped) and can surface it once crawled \u2014 a reasonable check for a named company. Otherwise tell the user the leads will populate in Leadbay over the next minutes\u2013hours; to pull those specific companies back through the MCP in bulk, **re-run the same import later** (the now-crawled domains match). Do NOT promise \`leadbay_pull_leads\` or \`import_status\` will list the late additions.
8473
+
8474
+ So when reporting an import: count \`uncrawled\` rows as **pending**, never as failures. Do NOT tell the user these rows "failed", were "rejected", had "bad/unreachable websites", or point to a backend problem \u2014 that is wrong and needlessly erodes trust in the whole lead set. A high \`uncrawled\` share on a fresh list is normal and expected, not a red flag.
8475
+
8476
+ How the OTHER reasons map to the "Need attention" bucket (see the render block above) \u2014 none of these should be lumped in with \`uncrawled\`/pending, but each is still surfaced to the user, not suppressed:
8477
+
8478
+ - \`malformed\` (row couldn't be parsed) and \`internal_error\` (a real backend error) are genuine failures \u2014 flag them plainly.
8479
+ - \`no_match\` on a public-mailbox domain (gmail.com, outlook.com, \u2026) means no company domain was resolvable from that row \u2014 surface it so the user can supply a real company domain. Not a crawler failure.
8480
+ - \`ambiguous\` rows matched several candidates \u2014 surface them as needing disambiguation via \`leadbay_resolve_import_rows\`. Not a failure, but the user still needs to act.
8481
+
8482
+
8272
8483
 
8273
8484
  ---
8274
8485
 
@@ -8296,14 +8507,15 @@ User picks \u2192 call the matching \`Calls\` tool. Constraints: 2\u20134 mutual
8296
8507
  |------------------------------------------------|---------------------------------------------------------------|--------------------------------------------------------|
8297
8508
  | Status: running | "Check progress" | leadbay_import_status(handle_id) |
8298
8509
  | Status: complete, imports succeeded | "Run AI qualification on the imported leads" | leadbay_bulk_qualify_leads([leadIds]) \u2014 or use leadbay_import_and_qualify next time |
8510
+ | Pending-crawl (\`uncrawled\`) rows present | "Re-run the import for those domains later, once Leadbay has crawled them" | leadbay_import_leads (re-run with just the uncrawled domains, later \u2014 they re-reconcile once crawled). NOTE: not a live-fetch of the added leads; those populate in the user's Leadbay account as the crawl completes |
8299
8511
  | Ambiguous / unresolved rows present | "Resolve the ambiguous rows" | leadbay_resolve_import_rows(records, identity_mappings)|
8300
- | Failed rows from bad mappings | "Check the org's mappable fields and remap" | leadbay_list_mappable_fields |
8512
+ | \`malformed\` / bad-mapping rows present | "Check the org's mappable fields and remap the bad rows" | leadbay_list_mappable_fields |
8301
8513
  | User wants to see the imported leads | "See the imported leads in your view" | leadbay_pull_leads |
8302
8514
  | User had follow-up intent for the imports | "Prep outreach for [a specific imported lead]" | leadbay_prepare_outreach(leadId) |
8303
8515
  `;
8304
- var leadbay_import_status = `Retrieve the current state of an async lead import. Pass \`handle_id\` returned by \`leadbay_import_leads({wait_for_completion:false})\`, or pass legacy \`importIds[]\` to inspect backend wizard rows. This status call performs a single refresh pass and never polls in a loop.
8516
+ var leadbay_import_status = `Retrieve the current **status/progress** of a lead import. Pass \`handle_id\` \u2014 returned by either \`leadbay_import_leads\` OR \`leadbay_import_and_qualify\` when called with \`wait_for_completion:false\` \u2014 to resolve the stored result (leads + not_imported) once that async run has completed in this MCP instance. **If you were given a \`handle_id\`, poll with it, not with \`importIds[]\`** \u2014 only the \`handle_id\` path returns the stored result/not_imported breakdown. Pass \`importIds[]\` (a completed import returns \`importIds\`; \`leadbay_import_and_qualify\` returns \`import_ids\`) only when you don't have a handle, to refresh the backend wizard rows' phase + record counts. Note: the \`importIds[]\` path returns status/progress only \u2014 it does NOT re-reconcile records or return refreshed leads/not_imported. This status call performs a single refresh pass and never polls in a loop.
8305
8517
 
8306
- WHEN TO USE: after leadbay_import_leads or leadbay_import_and_qualify returns \`{status:'running', handle_id}\` for the import phase, call this tool later to retrieve progress or the final import result without re-running the import.
8518
+ WHEN TO USE: after an async import (\`leadbay_import_leads\` OR \`leadbay_import_and_qualify\` with \`wait_for_completion:false\`) returns \`{status:'running', handle_id}\`, poll with that \`handle_id\`; OR to check whether a finished import is still processing. This tool does NOT surface the leads Leadbay adds later for pending-crawl (\`uncrawled\`) rows \u2014 those populate in the user's Leadbay account as the crawl completes; no tool here fetches them on demand (re-run the import to pull them back through the MCP).
8307
8519
 
8308
8520
  WHEN NOT TO USE: for qualification handles returned as \`qualify_id\` \u2014 use leadbay_qualify_status for those; or when you still want the legacy blocking behavior from leadbay_import_leads with \`wait_for_completion=true\`.
8309
8521
 
@@ -8325,19 +8537,39 @@ After the status line, propose the obvious refresh / progress-check / recovery a
8325
8537
 
8326
8538
  Specifically for import status:
8327
8539
 
8328
- - Running \u2192 \`"\u23F3 Import still running \u2014 N% complete; check back in ~M minutes."\`
8329
- - Complete \u2192 \`"\u2713 Import complete \u2014 N leads imported, M failed."\`
8330
- - Error / failed \u2192 \`"\u26A0 Import failed: <error>. See leadbay_resolve_import_rows for diagnosis."\`
8540
+ This tool returns \`status\`, \`importIds\`, and \`progress\` ({phase, records_processed, records_total}). It carries a \`result\` object (with \`leads\` + \`not_imported\`) ONLY when resolving an async \`handle_id\` whose run completed in this MCP instance \u2014 the \`importIds[]\` status-check path does NOT return \`result\`. **Render only from the fields actually present; never invent counts.**
8541
+
8542
+ Caveat on \`progress\`: \`records_processed\` counts only the rows that MATCHED an existing lead (backend \`imported_records\`), not every row that finished processing \u2014 so for a complete import whose rows are mostly/all \`uncrawled\` (pending crawl), \`records_processed\` is legitimately low or 0. Never read a low \`records_processed\` on a \`complete\` import as "stuck" or "failed": once \`status:"complete"\`, processing is done; the pending-crawl rows just matched no existing lead yet.
8543
+
8544
+ - Running \u2192 \`"\u23F3 Import still running \u2014 phase <phase>; check back in ~M minutes."\` (use the phase; don't turn the matched-count into an "X/Y processed" progress bar).
8545
+ - Complete, **no \`result\`** (the usual \`importIds\` status check) \u2192 \`"\u2713 Import complete."\` Do NOT append a \`records_processed/records_total\` fraction (it undercounts pending-crawl rows and looks stuck) and do NOT report pending-crawl / need-attention bucket counts \u2014 the row-level \`not_imported\` breakdown isn't in this response.
8546
+ - Complete, **\`result\` present AND it was a dry run** (\`result.dry_run:true\`, or every \`result.not_imported\` row has \`reason:"dry_run"\`) \u2192 this resolved handle was a VALIDATION pass, nothing committed. Render \`"\u{1F50E} Dry run complete \u2014 V rows validated, nothing imported. Re-run without dry_run to commit."\` \u2014 do NOT render it as a real import completion or use the pending/attention buckets.
8547
+ - Complete, **\`result\` present** (async handle resolved, real import) \u2192 then, and only then, partition \`result.not_imported\` as in the shared import-result render block below \u2014 \`"\u2713 Import complete \u2014 N imported \xB7 P pending crawl \xB7 Q need attention"\` where **pending crawl** is \`uncrawled\` rows that HAVE a \`domain\` (not failures) and no-\`domain\` \`uncrawled\` rows fall under need-attention. Drop any zero segment.
8548
+ - Error / failed \u2192 \`"\u26A0 Import failed: <error>. See leadbay_resolve_import_rows for diagnosis."\` \u2014 reserve this ONLY for a true transport/backend error on the import itself, never for \`uncrawled\` rows.
8549
+
8550
+ **\`uncrawled\` is NOT a failed import \u2014 it means "pending a crawl".** A row lands \`uncrawled\` when Leadbay hasn't matched or crawled that domain **yet** \u2014 the row simply didn't match an existing lead at import time and isn't a public-mailbox domain. It does NOT mean the import failed, and it is NOT a verdict that the website is broken (the tool doesn't check whether the URL resolves \u2014 so don't claim the site is bad, but don't guarantee it's valid either).
8551
+
8552
+ **One caveat \u2014 \`uncrawled\` only means "pending" when the row actually had a website.** A row imported by name / CRM id / registry number only (no \`LEAD_WEBSITE\` mapped) that finds no existing match ALSO lands \`uncrawled\`, but there's no domain for Leadbay to crawl \u2014 so it will NOT self-resolve via a late crawl. For those name-only rows, don't give the "Leadbay is crawling it" reassurance; tell the user to supply a company website (or another resolvable identity) and re-import. So: \`uncrawled\` + a website \u2192 genuinely pending a background crawl; \`uncrawled\` + no website \u2192 the user needs to add an identity, it won't crawl on its own. The import itself completed successfully; Leadbay then crawls the domain in the background and adds the lead asynchronously (a *late import*), so most of these rows resolve on their own within minutes to hours. Where do those late-added leads show up? **In the user's Leadbay account as the crawl completes.** \`leadbay_import_status\` does NOT return them \u2014 it only refreshes status/progress. There's no bulk "list the leads this import just added" call: \`leadbay_pull_leads\` reads the active lens's wishlist, so an imported lead not admitted to that lens won't appear there. For **one specific company by name**, \`leadbay_research_lead_by_name_fuzzy\` searches across the visible Leadbay corpus (not lens-scoped) and can surface it once crawled \u2014 a reasonable check for a named company. Otherwise tell the user the leads will populate in Leadbay over the next minutes\u2013hours; to pull those specific companies back through the MCP in bulk, **re-run the same import later** (the now-crawled domains match). Do NOT promise \`leadbay_pull_leads\` or \`import_status\` will list the late additions.
8553
+
8554
+ So when reporting an import: count \`uncrawled\` rows as **pending**, never as failures. Do NOT tell the user these rows "failed", were "rejected", had "bad/unreachable websites", or point to a backend problem \u2014 that is wrong and needlessly erodes trust in the whole lead set. A high \`uncrawled\` share on a fresh list is normal and expected, not a red flag.
8555
+
8556
+ How the OTHER reasons map to the "Need attention" bucket (see the render block above) \u2014 none of these should be lumped in with \`uncrawled\`/pending, but each is still surfaced to the user, not suppressed:
8557
+
8558
+ - \`malformed\` (row couldn't be parsed) and \`internal_error\` (a real backend error) are genuine failures \u2014 flag them plainly.
8559
+ - \`no_match\` on a public-mailbox domain (gmail.com, outlook.com, \u2026) means no company domain was resolvable from that row \u2014 surface it so the user can supply a real company domain. Not a crawler failure.
8560
+ - \`ambiguous\` rows matched several candidates \u2014 surface them as needing disambiguation via \`leadbay_resolve_import_rows\`. Not a failure, but the user still needs to act.
8561
+
8331
8562
 
8332
8563
  ---
8333
8564
 
8334
8565
  ## NEXT STEPS
8335
8566
 
8336
- | Observation | Suggest | Calls |
8337
- |-------------------------|----------------------------------------|--------------------------------|
8338
- | Status: complete | "See the imported leads" | leadbay_pull_leads |
8339
- | Status: running | "Check again in N minutes" | leadbay_import_status \u2014 re-call|
8340
- | Status: error / failed | "Diagnose the failure" | leadbay_resolve_import_rows |
8567
+ | Observation | Suggest | Calls |
8568
+ |--------------------------------------|------------------------------------------------------|--------------------------------|
8569
+ | Status: complete | "See the imported (matched) leads" | leadbay_pull_leads |
8570
+ | Pending-crawl (\`uncrawled\`) rows | "Re-run the import for those domains later, once Leadbay has crawled them" | leadbay_import_leads (re-run with just the uncrawled domains, later \u2014 they re-reconcile once crawled). The added leads otherwise populate in the user's Leadbay account as the crawl completes; no live-fetch here |
8571
+ | Status: running | "Check again in N minutes" | leadbay_import_status \u2014 re-call|
8572
+ | Status: error / failed (true error) | "Diagnose the failure" | leadbay_resolve_import_rows |
8341
8573
  `;
8342
8574
  var leadbay_launch_bulk_enrichment = `Launch a bulk-enrichment job against the current selection. The backend requires \`email=true\` OR \`phone=true\` (both can be true). Returns 204 with no body \u2014 there is no bulk_id and no per-job status endpoint. Track results by polling individual leads via leadbay_get_contacts after ~60s; a contact is done for this run only when the REQUESTED channel landed (requested \`email\` and/or \`phone_number\` present), not \`contact.enrichment.done\` alone (that flag is already true for a contact enriched on the other channel earlier). \`dry_run:true\` returns the call shape without contacting the backend.
8343
8575
 
@@ -9938,7 +10170,7 @@ When \`_meta.match_candidates\` is non-empty, prepend one extra NEXT STEPS row:
9938
10170
  `;
9939
10171
  var leadbay_resolve_import_rows = `Resolve messy CSV-shaped lead rows against Leadbay before file import. The tool sends each row's available identity signals to \`POST /leads/resolve\`, returns matched lead IDs or ambiguous candidate IDs, and produces \`records_for_import\` plus a SAFE identity-only \`mappings_for_import\` starting point for leadbay_import_leads / leadbay_import_and_qualify. This tool deliberately does not try to understand every CSV dialect; the agent should inspect the file, derive clean helper columns when useful, pass explicit \`identity_mappings\`, and build the final CRM mapping from \`mapping_guidance\`.
9940
10172
 
9941
- WHEN TO USE: before importing user-supplied files when domains, names, CRM IDs, registry numbers, or Leadbay IDs may be inconsistently formatted; when the agent needs to pre-resolve messy rows, inspect ambiguous candidates, or prepare LEADBAY_ID values for the import composites. For contact-only files, first derive company website/domain from business contact emails where possible, while ignoring consumer mailbox domains. Deterministic matches get a LEADBAY_ID column inserted so the standard import commits immediately. Ambiguous rows are deliberately left without LEADBAY_ID; inspect candidates and choose one only when the evidence is good. Rows with websites but no match can still be imported; Leadbay may crawl and match them later, and leadbay_import_status can surface late matches.
10173
+ WHEN TO USE: before importing user-supplied files when domains, names, CRM IDs, registry numbers, or Leadbay IDs may be inconsistently formatted; when the agent needs to pre-resolve messy rows, inspect ambiguous candidates, or prepare LEADBAY_ID values for the import composites. For contact-only files, first derive company website/domain from business contact emails where possible, while ignoring consumer mailbox domains. Deterministic matches get a LEADBAY_ID column inserted so the standard import commits immediately. Ambiguous rows are deliberately left without LEADBAY_ID; inspect candidates and choose one only when the evidence is good. Rows with websites but no match can still be imported; Leadbay may crawl and match them later (a late import), and those leads then populate in the user's Leadbay account as the crawl completes (no tool here fetches them on demand \u2014 re-run the import later to pull them back through the MCP).
9942
10174
 
9943
10175
  WHEN NOT TO USE: for prospect discovery from scratch (use leadbay_pull_leads); for one known company profile (use leadbay_research_lead_by_name_fuzzy / leadbay_research_lead_by_id); or when the file already has clean, final LEADBAY_ID/CRM_ID/SIREN mappings and no row-level identity disambiguation is needed.
9944
10176
 
@@ -9971,7 +10203,7 @@ Below the table, a one-liner: \`"Ready: K rows \xB7 Ambiguous: A rows \xB7 Unmat
9971
10203
  |----------------------------------------|-------------------------------------------------------------|--------------------------------------------------------|
9972
10204
  | All rows resolved cleanly | "Import these rows now" | leadbay_import_leads(records_for_import, mappings_for_import) |
9973
10205
  | Ambiguous rows present | "Inspect candidates for each ambiguous row" | (re-call with include_candidate_profiles=true) |
9974
- | Unmatched rows but websites present | "Import anyway \u2014 Leadbay will crawl and match later" | leadbay_import_leads (status check after) |
10206
+ | Unmatched rows but websites present | "Import anyway \u2014 Leadbay crawls & adds them to your account later" | leadbay_import_leads (the late-added leads populate in Leadbay; re-run the import to pull them back through the MCP) |
9975
10207
  | User wants to skip rows they can't ID | "Drop unmatched rows and import the rest" | leadbay_import_leads (with filtered records) |
9976
10208
  `;
9977
10209
  var leadbay_scan_portfolio_signals = `## WHEN TO USE
@@ -10336,6 +10568,67 @@ WHEN NOT TO USE: to READ the questions (use leadbay_get_qualification_questions)
10336
10568
 
10337
10569
  After a change, confirm in one line \u2014 e.g. **"Added 1 question \u2014 you now score leads against 4 questions."** or **"Removed 'the flooring question' \u2014 3 questions remain."** Then list the resulting questions as a numbered list. When the result is a non-changing preview (a removal awaiting confirmation), surface the \`hint\` (what would be removed) and ask the user to confirm \u2014 do NOT auto-confirm.
10338
10570
  `;
10571
+ var leadbay_set_telemetry = `## WHEN TO USE
10572
+
10573
+ Trigger phrases: "disable telemetry", "turn off telemetry", "opt out of analytics", "stop sending usage data", "enable telemetry", "turn analytics back on", "is telemetry on", "is my usage being tracked", "what's my telemetry setting".
10574
+
10575
+ **Memory:** recall + capture via \`leadbay_agent_memory_*\` tools.
10576
+
10577
+ Prefer when: user wants to change or read the telemetry/analytics on-off preference for their account
10578
+
10579
+ Examples that SHOULD invoke this tool:
10580
+ - "Turn off telemetry, I don't want my usage tracked."
10581
+ - "Re-enable analytics for my account."
10582
+ - "Is telemetry currently on for me?"
10583
+
10584
+ Examples that should NOT invoke this tool (sound similar, route elsewhere):
10585
+ - "I want to report a bug in the pull-leads tool."
10586
+ - "Send feedback to the Leadbay team."
10587
+ - "Why isn't my event showing up in PostHog?"
10588
+
10589
+ ## RENDER (quick)
10590
+
10591
+ One short confirmation line reflecting the result: state whether telemetry is
10592
+ now ON or OFF (or, for \`status\`, what it currently is) and \u2014 from \`hint\` \u2014
10593
+ the one-line way to flip it. No table; a single sentence is enough.
10594
+
10595
+ ---
10596
+
10597
+ Enable, disable, or check **product-usage telemetry** for the current user.
10598
+
10599
+ Telemetry (PostHog analytics \u2014 which tools fire, durations, error rates) is
10600
+ **ON by default** (opt-out model). It does not capture tool argument bodies,
10601
+ response bodies, or lead PII. This is a granular endpoint tool, so
10602
+ \`_triggered_by\` is optional like other granular tools; when it is present on an
10603
+ opt-out attempt, the MCP server suppresses/sanitizes the privacy-control
10604
+ telemetry paths so the opt-out prompt is not recorded. This tool is the
10605
+ in-product control so a user can change or check the setting without editing
10606
+ config. The preference is stored on the user's Leadbay
10607
+ account. The **hosted/web connector** reads it per-request and stops sending a
10608
+ disabled user's events. A **local (self-hosted / stdio) install** decides
10609
+ telemetry at process start from the \`LEADBAY_TELEMETRY_ENABLED\` env var and does
10610
+ NOT consult this account flag \u2014 so a local user who wants to opt out should also
10611
+ set \`LEADBAY_TELEMETRY_ENABLED=false\`. Do NOT tell a local user that disabling
10612
+ here alone stops their events.
10613
+
10614
+ Parameter:
10615
+
10616
+ - **\`action\`** \u2014 \`"enable"\` | \`"disable"\` | \`"status"\`. Defaults to \`"status"\`
10617
+ (a bare call safely reports the setting without changing it).
10618
+
10619
+ Returns:
10620
+
10621
+ - **\`telemetry_enabled\`** \u2014 the setting AFTER this call.
10622
+ - **\`changed\`** \u2014 whether this call actually flipped it (\`false\` for \`status\`
10623
+ and for a no-op set, e.g. disabling when already off).
10624
+ - **\`action\`**, **\`region\`**, and a one-line **\`hint\`** describing how to flip it.
10625
+
10626
+ Setting the preference takes effect going forward on the **hosted connector**,
10627
+ which reads the flag per-request and stops emitting analytics for an opted-out
10628
+ user. On a local install the account flag is not consulted (see above).
10629
+ \`enable\`/\`disable\` are idempotent \u2014 setting the value it already has is a no-op
10630
+ that reports \`changed: false\`.
10631
+ `;
10339
10632
  var leadbay_set_user_prompt = `Set the org's intelligence-refinement prompt \u2014 free-text instruction that steers Leadbay's lead recommendations beyond firmographics. Admin-only. Setting this clears any pending clarification and triggers a full intelligence regeneration (web search + high-reasoning). \`dry_run:true\` returns the call shape without contacting the backend.
10340
10633
 
10341
10634
  WHEN TO USE: low-level.
@@ -14891,6 +15184,91 @@ var dislikeLead = {
14891
15184
  }
14892
15185
  };
14893
15186
 
15187
+ // ../core/dist/tools/set-telemetry.js
15188
+ function isEnabled(telemetry_enabled) {
15189
+ return telemetry_enabled !== false;
15190
+ }
15191
+ var LOCAL_OFF_CAVEAT = " On a local (self-hosted / stdio) install, also set LEADBAY_TELEMETRY_ENABLED=false to stop events there \u2014 the account flag alone does not.";
15192
+ var LOCAL_ON_CAVEAT = " This is your account setting; a local (self-hosted / stdio) install follows LEADBAY_TELEMETRY_ENABLED at startup instead, so it may not be sending events regardless.";
15193
+ var VALID_ACTIONS = ["enable", "disable", "status"];
15194
+ var setTelemetry = {
15195
+ name: "leadbay_set_telemetry",
15196
+ annotations: {
15197
+ title: "Enable, disable, or check product-usage telemetry",
15198
+ readOnlyHint: false,
15199
+ destructiveHint: false,
15200
+ idempotentHint: true,
15201
+ openWorldHint: true
15202
+ },
15203
+ description: leadbay_set_telemetry,
15204
+ optional: true,
15205
+ write: true,
15206
+ inputSchema: {
15207
+ type: "object",
15208
+ properties: {
15209
+ action: {
15210
+ type: "string",
15211
+ enum: ["enable", "disable", "status"],
15212
+ description: "enable / disable flip telemetry for the user; status just reports the current setting. Defaults to status."
15213
+ }
15214
+ },
15215
+ additionalProperties: false
15216
+ },
15217
+ // No outputSchema: the result is a small self-describing object (telemetry_enabled,
15218
+ // changed, action, region, hint — all documented in the description). Declaring
15219
+ // an outputSchema would opt this tool into the structuredContent conformance
15220
+ // suite for no benefit here.
15221
+ execute: async (client, params) => {
15222
+ const action = params.action ?? "status";
15223
+ if (!VALID_ACTIONS.includes(action)) {
15224
+ return {
15225
+ error: true,
15226
+ code: "BAD_ACTION",
15227
+ message: `Unknown action "${action}".`,
15228
+ hint: `Use one of: ${VALID_ACTIONS.join(", ")}. Defaults to "status".`
15229
+ };
15230
+ }
15231
+ const meBefore = await client.resolveMe(true);
15232
+ const currentlyEnabled = isEnabled(meBefore.telemetry_enabled);
15233
+ if (action === "status") {
15234
+ return {
15235
+ telemetry_enabled: currentlyEnabled,
15236
+ changed: false,
15237
+ action,
15238
+ region: client.region,
15239
+ hint: currentlyEnabled ? "Telemetry is ON for your account. Call with action:'disable' to opt out." + LOCAL_ON_CAVEAT : "Telemetry is OFF for your account. Call with action:'enable' to opt back in." + LOCAL_OFF_CAVEAT
15240
+ };
15241
+ }
15242
+ const target = action === "enable";
15243
+ if (target === currentlyEnabled) {
15244
+ if (target) {
15245
+ client.setCachedTelemetryEnabled(true);
15246
+ }
15247
+ return {
15248
+ telemetry_enabled: currentlyEnabled,
15249
+ changed: false,
15250
+ action,
15251
+ region: client.region,
15252
+ hint: target ? "Telemetry was already ON for your account; nothing to change. Call leadbay_set_telemetry with action:'disable' to opt out." + LOCAL_ON_CAVEAT : "Telemetry was already OFF for your account; nothing to change. Call leadbay_set_telemetry with action:'enable' to opt back in." + LOCAL_OFF_CAVEAT
15253
+ };
15254
+ }
15255
+ await client.requestVoid("POST", "/users/telemetry", {
15256
+ telemetry_enabled: target
15257
+ });
15258
+ client.setCachedTelemetryEnabled(target);
15259
+ return {
15260
+ telemetry_enabled: target,
15261
+ changed: true,
15262
+ action,
15263
+ region: client.region,
15264
+ // Honest about WHERE the account flag is enforced: the hosted connector
15265
+ // reads it per-request; a local/stdio install needs the env var (see
15266
+ // LOCAL_OFF_CAVEAT). Never imply the account flag alone stops local events.
15267
+ hint: target ? "Telemetry is now ON for your account \u2014 thanks for helping improve Leadbay." + LOCAL_ON_CAVEAT : "Telemetry is now OFF for your account \u2014 the hosted Leadbay connector stops sending your product-usage events." + LOCAL_OFF_CAVEAT
15268
+ };
15269
+ }
15270
+ };
15271
+
14894
15272
  // ../core/dist/tools/add-contact.js
14895
15273
  var addContact = {
14896
15274
  name: "leadbay_add_contact",
@@ -22935,6 +23313,7 @@ var granularWriteTools = [
22935
23313
  removePushback,
22936
23314
  previewBulkEnrichment,
22937
23315
  launchBulkEnrichment,
23316
+ setTelemetry,
22938
23317
  createCustomField
22939
23318
  ];
22940
23319
  var granularTools = [
@@ -24111,6 +24490,7 @@ function buildServer(client, opts = {}) {
24111
24490
  if (opts.includeWrite) {
24112
24491
  exposedTools.push(...compositeWriteTools);
24113
24492
  }
24493
+ exposedTools.push(setTelemetry);
24114
24494
  if (opts.includeAdvanced) {
24115
24495
  exposedTools.push(...granularReadTools);
24116
24496
  if (opts.includeWrite) {
@@ -24315,7 +24695,7 @@ function buildServer(client, opts = {}) {
24315
24695
  latency_ms: meta.latency_ms ?? null,
24316
24696
  retry_after: meta.retry_after ?? null,
24317
24697
  http_status: meta.http_status,
24318
- triggered_by,
24698
+ ...triggered_by !== void 0 ? { triggered_by } : {},
24319
24699
  source: "business"
24320
24700
  };
24321
24701
  };
@@ -24440,15 +24820,17 @@ ${url}
24440
24820
  };
24441
24821
  const pendingText = formatErrorForLLM(envelope);
24442
24822
  const pendingDur = Date.now() - callStart;
24443
- telemetry2.captureToolCall({
24444
- tool: name,
24445
- ok: false,
24446
- duration_ms: pendingDur,
24447
- format: "error-envelope",
24448
- bytes: pendingText.length,
24449
- error_code: envelope.code,
24450
- triggered_by
24451
- });
24823
+ if (name !== "leadbay_set_telemetry") {
24824
+ telemetry2.captureToolCall({
24825
+ tool: name,
24826
+ ok: false,
24827
+ duration_ms: pendingDur,
24828
+ format: "error-envelope",
24829
+ bytes: pendingText.length,
24830
+ error_code: envelope.code,
24831
+ triggered_by
24832
+ });
24833
+ }
24452
24834
  if (DEBUG_ON) {
24453
24835
  process.stderr.write(
24454
24836
  `[leadbay-mcp debug] tool=${name} dur=${pendingDur}ms ok=false code=${envelope.code} (auth-bootstrap, no-sentry)
@@ -24464,7 +24846,7 @@ ${url}
24464
24846
  const envelope = {
24465
24847
  error: true,
24466
24848
  code: "LAST_PROMPT_REQUIRED",
24467
- message: "Every call to this composite tool must carry `_triggered_by` \u2014 the verbatim part of the user's most recent message this call is acting upon (secrets stripped).",
24849
+ message: "Every call to this tool must carry `_triggered_by` \u2014 the verbatim part of the user's most recent message this call is acting upon (secrets stripped).",
24468
24850
  hint: "Re-call with `_triggered_by` set to the literal user-message slice this invocation is fulfilling."
24469
24851
  };
24470
24852
  const guardText = formatErrorForLLM(envelope);
@@ -24518,35 +24900,38 @@ ${url}
24518
24900
  const envText = formatErrorForLLM(result);
24519
24901
  const envDur = Date.now() - callStart;
24520
24902
  const envCode = result.code ?? "Error";
24521
- if (envCode === "QUOTA_EXCEEDED") {
24522
- telemetry2.captureQuotaHit({
24523
- tool: name,
24524
- retry_after_s: result._meta?.retry_after,
24525
- endpoint: result._meta?.endpoint
24526
- });
24527
- }
24528
- telemetry2.captureToolCall({
24529
- tool: name,
24530
- ok: false,
24531
- duration_ms: envDur,
24532
- format: "error-envelope",
24533
- bytes: envText.length,
24534
- error_code: envCode,
24535
- triggered_by
24536
- });
24537
- if (COMPOSITE_FILE_TOOL_NAMES.has(name)) {
24538
- telemetry2.captureCompositeCall({
24903
+ const isPrivacyControl = name === "leadbay_set_telemetry";
24904
+ if (!isPrivacyControl) {
24905
+ if (envCode === "QUOTA_EXCEEDED") {
24906
+ telemetry2.captureQuotaHit({
24907
+ tool: name,
24908
+ retry_after_s: result._meta?.retry_after,
24909
+ endpoint: result._meta?.endpoint
24910
+ });
24911
+ }
24912
+ telemetry2.captureToolCall({
24539
24913
  tool: name,
24540
- last_prompt: triggered_by ?? "",
24541
24914
  ok: false,
24542
24915
  duration_ms: envDur,
24543
- error_code: envCode
24916
+ format: "error-envelope",
24917
+ bytes: envText.length,
24918
+ error_code: envCode,
24919
+ triggered_by
24544
24920
  });
24921
+ if (COMPOSITE_FILE_TOOL_NAMES.has(name)) {
24922
+ telemetry2.captureCompositeCall({
24923
+ tool: name,
24924
+ last_prompt: triggered_by ?? "",
24925
+ ok: false,
24926
+ duration_ms: envDur,
24927
+ error_code: envCode
24928
+ });
24929
+ }
24930
+ telemetry2.captureException(
24931
+ result,
24932
+ buildBusinessCtx(name, result, triggered_by)
24933
+ );
24545
24934
  }
24546
- telemetry2.captureException(
24547
- result,
24548
- buildBusinessCtx(name, result, triggered_by)
24549
- );
24550
24935
  if (DEBUG_ON) {
24551
24936
  process.stderr.write(
24552
24937
  `[leadbay-mcp debug] tool=${name} dur=${envDur}ms ok=false code=${envCode}
@@ -24611,21 +24996,24 @@ ${url}
24611
24996
  const okText = response.content[0]?.text ?? "";
24612
24997
  const okBytes = typeof okText === "string" ? okText.length : 0;
24613
24998
  const okDur = Date.now() - callStart;
24614
- telemetry2.captureToolCall({
24615
- tool: name,
24616
- ok: true,
24617
- duration_ms: okDur,
24618
- format: "json",
24619
- bytes: okBytes,
24620
- triggered_by
24621
- });
24622
- if (COMPOSITE_FILE_TOOL_NAMES.has(name)) {
24623
- telemetry2.captureCompositeCall({
24999
+ const suppressSuccessfulTelemetryDisable = name === "leadbay_set_telemetry" && result !== null && typeof result === "object" && !Array.isArray(result) && result.action === "disable" && result.telemetry_enabled === false;
25000
+ if (!suppressSuccessfulTelemetryDisable) {
25001
+ telemetry2.captureToolCall({
24624
25002
  tool: name,
24625
- last_prompt: triggered_by ?? "",
24626
25003
  ok: true,
24627
- duration_ms: okDur
25004
+ duration_ms: okDur,
25005
+ format: "json",
25006
+ bytes: okBytes,
25007
+ triggered_by
24628
25008
  });
25009
+ if (COMPOSITE_FILE_TOOL_NAMES.has(name)) {
25010
+ telemetry2.captureCompositeCall({
25011
+ tool: name,
25012
+ last_prompt: triggered_by ?? "",
25013
+ ok: true,
25014
+ duration_ms: okDur
25015
+ });
25016
+ }
24629
25017
  }
24630
25018
  captureAgentMemoryTelemetry(name, result);
24631
25019
  captureFrictionTelemetry(name, result);
@@ -24643,8 +25031,10 @@ ${url}
24643
25031
  const errDur = Date.now() - callStart;
24644
25032
  const errText = formatErrorForLLM(err);
24645
25033
  const code = err?.code ?? err?.name ?? "Error";
25034
+ const skipAnalytics = name === "leadbay_set_telemetry";
25035
+ const sentryTriggeredBy = skipAnalytics ? void 0 : triggered_by;
24646
25036
  if (isLeadbayBusinessError(err)) {
24647
- if (err.code === "QUOTA_EXCEEDED") {
25037
+ if (!skipAnalytics && err.code === "QUOTA_EXCEEDED") {
24648
25038
  telemetry2.captureQuotaHit({
24649
25039
  tool: name,
24650
25040
  retry_after_s: err._meta?.retry_after,
@@ -24652,51 +25042,55 @@ ${url}
24652
25042
  });
24653
25043
  }
24654
25044
  const httpStatus2 = err._meta?.http_status;
24655
- telemetry2.captureToolCall({
24656
- tool: name,
24657
- ok: false,
24658
- duration_ms: errDur,
24659
- format: "error-envelope",
24660
- bytes: errText.length,
24661
- error_code: code,
24662
- ...typeof httpStatus2 === "number" ? { http_status: httpStatus2 } : {},
24663
- triggered_by
24664
- });
24665
- if (COMPOSITE_FILE_TOOL_NAMES.has(name)) {
24666
- telemetry2.captureCompositeCall({
25045
+ if (!skipAnalytics) {
25046
+ telemetry2.captureToolCall({
24667
25047
  tool: name,
24668
- last_prompt: triggered_by ?? "",
24669
25048
  ok: false,
24670
25049
  duration_ms: errDur,
25050
+ format: "error-envelope",
25051
+ bytes: errText.length,
24671
25052
  error_code: code,
24672
- ...typeof httpStatus2 === "number" ? { http_status: httpStatus2 } : {}
25053
+ ...typeof httpStatus2 === "number" ? { http_status: httpStatus2 } : {},
25054
+ triggered_by
24673
25055
  });
25056
+ if (COMPOSITE_FILE_TOOL_NAMES.has(name)) {
25057
+ telemetry2.captureCompositeCall({
25058
+ tool: name,
25059
+ last_prompt: triggered_by ?? "",
25060
+ ok: false,
25061
+ duration_ms: errDur,
25062
+ error_code: code,
25063
+ ...typeof httpStatus2 === "number" ? { http_status: httpStatus2 } : {}
25064
+ });
25065
+ }
24674
25066
  }
24675
- telemetry2.captureException(err, buildBusinessCtx(name, err, triggered_by));
25067
+ telemetry2.captureException(err, buildBusinessCtx(name, err, sentryTriggeredBy));
24676
25068
  } else {
24677
25069
  telemetry2.captureException(err, {
24678
25070
  tool: name,
24679
25071
  source: "unexpected",
24680
25072
  message: typeof err?.message === "string" ? err.message : void 0,
24681
- triggered_by
25073
+ ...sentryTriggeredBy !== void 0 ? { triggered_by: sentryTriggeredBy } : {}
24682
25074
  });
24683
- telemetry2.captureToolCall({
24684
- tool: name,
24685
- ok: false,
24686
- duration_ms: errDur,
24687
- format: "error-envelope",
24688
- bytes: errText.length,
24689
- error_code: code,
24690
- triggered_by
24691
- });
24692
- if (COMPOSITE_FILE_TOOL_NAMES.has(name)) {
24693
- telemetry2.captureCompositeCall({
25075
+ if (!skipAnalytics) {
25076
+ telemetry2.captureToolCall({
24694
25077
  tool: name,
24695
- last_prompt: triggered_by ?? "",
24696
25078
  ok: false,
24697
25079
  duration_ms: errDur,
24698
- error_code: code
25080
+ format: "error-envelope",
25081
+ bytes: errText.length,
25082
+ error_code: code,
25083
+ triggered_by
24699
25084
  });
25085
+ if (COMPOSITE_FILE_TOOL_NAMES.has(name)) {
25086
+ telemetry2.captureCompositeCall({
25087
+ tool: name,
25088
+ last_prompt: triggered_by ?? "",
25089
+ ok: false,
25090
+ duration_ms: errDur,
25091
+ error_code: code
25092
+ });
25093
+ }
24700
25094
  }
24701
25095
  }
24702
25096
  if (DEBUG_ON) {
@@ -24830,7 +25224,7 @@ function parseWriteEnv(env = process.env) {
24830
25224
  }
24831
25225
 
24832
25226
  // src/http-server.ts
24833
- var VERSION = true ? "0.24.1" : "0.0.0-dev";
25227
+ var VERSION = true ? "0.25.0" : "0.0.0-dev";
24834
25228
  var PORT = Number(process.env.PORT ?? 8080);
24835
25229
  var HOST = process.env.HOST ?? "0.0.0.0";
24836
25230
  var logger = {
@@ -24844,6 +25238,9 @@ var logger = {
24844
25238
  var telemetry = initTelemetry({ version: VERSION, logger });
24845
25239
  var IDENTITY_RESOLVE_TIMEOUT_MS = 1500;
24846
25240
  async function resolveIdentity(client) {
25241
+ return (await resolveTelemetryContext(client)).identity;
25242
+ }
25243
+ async function resolveTelemetryContext(client) {
24847
25244
  const region = client.region;
24848
25245
  try {
24849
25246
  const me = await Promise.race([
@@ -24852,34 +25249,69 @@ async function resolveIdentity(client) {
24852
25249
  (resolve) => setTimeout(() => resolve(null), IDENTITY_RESOLVE_TIMEOUT_MS)
24853
25250
  )
24854
25251
  ]);
24855
- if (!me) return { distinctId: "mcp:unknown", region };
25252
+ if (!me) return { identity: { distinctId: "mcp:unknown", region }, enabled: false, forceClosed: true };
24856
25253
  const distinctId = me.email ?? (me.id ? `mcp:user-${me.id}` : "mcp:unknown");
24857
25254
  return {
24858
- distinctId,
24859
- groups: me.organization?.id ? { organization: me.organization.id } : void 0,
24860
- region,
24861
- // name/email so leadbay_send_feedback attributes correctly on HTTP — the
24862
- // module-scoped `me` is never populated here (Codex P2).
24863
- ...me.name ? { name: me.name } : {},
24864
- ...me.email ? { email: me.email } : {}
25255
+ identity: {
25256
+ distinctId,
25257
+ groups: me.organization?.id ? { organization: me.organization.id } : void 0,
25258
+ region,
25259
+ // name/email so leadbay_send_feedback attributes correctly on HTTP — the
25260
+ // module-scoped `me` is never populated here (Codex P2).
25261
+ ...me.name ? { name: me.name } : {},
25262
+ ...me.email ? { email: me.email } : {}
25263
+ },
25264
+ // Known preference: honor the per-user opt-out. Absent field → enabled
25265
+ // (older backend / opt-out default). A clean read is NOT force-closed.
25266
+ enabled: me.telemetry_enabled !== false,
25267
+ forceClosed: false
24865
25268
  };
24866
25269
  } catch (err) {
24867
25270
  logger.warn?.(`telemetry identity resolve failed: ${err?.message ?? err}`);
24868
- return { distinctId: "mcp:unknown", region };
25271
+ return { identity: { distinctId: "mcp:unknown", region }, enabled: false, forceClosed: true };
24869
25272
  }
24870
25273
  }
24871
- function bindTelemetryIdentity(base, identity) {
25274
+ function suppressTelemetry(opts) {
25275
+ const { stamped, cached, forceClosed, sessionOptedOut, fallbackEnabled } = opts;
25276
+ if (stamped) return cached === false;
25277
+ if (forceClosed || cached === false || sessionOptedOut) return true;
25278
+ if (cached === true) return false;
25279
+ return !fallbackEnabled;
25280
+ }
25281
+ async function telemetryHandleForRequest(client) {
25282
+ const { identity, enabled, forceClosed } = await resolveTelemetryContext(client);
25283
+ const isSuppressed = () => suppressTelemetry({
25284
+ stamped: client.cachedTelemetryStamped(),
25285
+ cached: client.cachedTelemetryEnabled(),
25286
+ forceClosed,
25287
+ sessionOptedOut: false,
25288
+ // no session on the streamable path
25289
+ fallbackEnabled: enabled
25290
+ });
25291
+ return bindTelemetryIdentity(telemetry, identity, isSuppressed);
25292
+ }
25293
+ function bindTelemetryIdentity(base, identity, isSuppressed) {
25294
+ const on = (fn) => (...a) => {
25295
+ if (isSuppressed?.()) return;
25296
+ fn(...a);
25297
+ };
24872
25298
  return {
24873
25299
  ...base,
24874
- captureToolCall: (p) => base.captureToolCall(p, identity),
24875
- captureCompositeCall: (p) => base.captureCompositeCall(p, identity),
24876
- captureQuotaHit: (p) => base.captureQuotaHit(p, identity),
24877
- captureTopupLink: (p) => base.captureTopupLink(p, identity),
24878
- captureStartup: (p) => base.captureStartup(p, identity),
24879
- captureAgentMemoryCaptured: (p) => base.captureAgentMemoryCaptured(p, identity),
24880
- captureAgentMemoryRecalled: (p) => base.captureAgentMemoryRecalled(p, identity),
24881
- captureAgentMemoryPruned: (p) => base.captureAgentMemoryPruned(p, identity),
24882
- captureFrictionReported: (p) => base.captureFrictionReported(p, identity),
25300
+ captureToolCall: on((p) => base.captureToolCall(p, identity)),
25301
+ captureCompositeCall: on((p) => base.captureCompositeCall(p, identity)),
25302
+ captureQuotaHit: on((p) => base.captureQuotaHit(p, identity)),
25303
+ captureTopupLink: on((p) => base.captureTopupLink(p, identity)),
25304
+ captureStartup: on((p) => base.captureStartup(p, identity)),
25305
+ captureAgentMemoryCaptured: on((p) => base.captureAgentMemoryCaptured(p, identity)),
25306
+ captureAgentMemoryRecalled: on((p) => base.captureAgentMemoryRecalled(p, identity)),
25307
+ captureAgentMemoryPruned: on((p) => base.captureAgentMemoryPruned(p, identity)),
25308
+ captureFrictionReported: on((p) => base.captureFrictionReported(p, identity)),
25309
+ captureException: on((err, ctx) => base.captureException(err, ctx)),
25310
+ // captureFeedback is NOT gated by isSuppressed (Codex P2): leadbay_send_feedback
25311
+ // is an explicit user-initiated "deliver my message to the team" action, not
25312
+ // passive telemetry. Opting out of analytics must not silently drop the user's
25313
+ // own feedback (it would return sent:false). Identity still rides along so the
25314
+ // Sentry feedback is attributed.
24883
25315
  captureFeedback: (message, opts) => base.captureFeedback(message, opts, identity),
24884
25316
  identify: async () => {
24885
25317
  },
@@ -24995,10 +25427,9 @@ async function handleStreamable(c, resourcePath) {
24995
25427
  if (resolved.authState === "missing" || resolved.authState === "expired") {
24996
25428
  return sendChallenge(c, resourcePath, resolved.authState);
24997
25429
  }
24998
- const identity = await resolveIdentity(resolved.client);
24999
25430
  const server = buildServerFromClient(
25000
25431
  resolved.client,
25001
- bindTelemetryIdentity(telemetry, identity)
25432
+ await telemetryHandleForRequest(resolved.client)
25002
25433
  );
25003
25434
  const transport = new StreamableHTTPServerTransport({
25004
25435
  sessionIdGenerator: void 0,
@@ -25046,16 +25477,51 @@ async function handleSse(c, resourcePath) {
25046
25477
  if (resolved.authState === "missing" || resolved.authState === "expired") {
25047
25478
  return sendChallenge(c, resourcePath, resolved.authState);
25048
25479
  }
25049
- const identity = await resolveIdentity(resolved.client);
25480
+ const { identity, enabled, forceClosed } = await resolveTelemetryContext(resolved.client);
25481
+ const session = {
25482
+ transport: void 0,
25483
+ // set below
25484
+ server: void 0,
25485
+ // set below
25486
+ createdAt: Date.now(),
25487
+ client: resolved.client,
25488
+ suppressed: !enabled,
25489
+ // Carry the resolve verdict's hard fail-closed (timeout/error at open). It
25490
+ // overrides even a cached `true` a late/orphaned resolveMe might populate,
25491
+ // and is cleared on the next SUCCESSFUL /messages refresh.
25492
+ forceClosed,
25493
+ refreshPending: false,
25494
+ refreshEpoch: 0
25495
+ };
25050
25496
  const env = c.env;
25051
25497
  const transport = new SSEServerTransport("/messages", env.outgoing);
25052
25498
  const server = buildServerFromClient(
25053
25499
  resolved.client,
25054
- bindTelemetryIdentity(telemetry, identity)
25500
+ // Suppression precedence via the shared suppressTelemetry() helper (Codex
25501
+ // P1/P2). An explicit same-session stamp (leadbay_set_telemetry) wins over
25502
+ // everything — so a mid-session opt-IN takes effect even if a background
25503
+ // refresh just failed closed. Otherwise any opt-out signal suppresses:
25504
+ // session.forceClosed (unreadable refresh), a cache read of `false`, OR
25505
+ // session.suppressed (a refresh that OBSERVED `false` this cycle, even if a
25506
+ // concurrent tool read left the shared cache at a stale `true`). Privacy
25507
+ // fails safe; only an explicit stamp can force emit.
25508
+ bindTelemetryIdentity(
25509
+ telemetry,
25510
+ identity,
25511
+ () => suppressTelemetry({
25512
+ stamped: resolved.client.cachedTelemetryStamped(),
25513
+ cached: resolved.client.cachedTelemetryEnabled(),
25514
+ forceClosed: session.forceClosed || session.refreshPending,
25515
+ sessionOptedOut: session.suppressed,
25516
+ fallbackEnabled: !session.suppressed
25517
+ })
25518
+ )
25055
25519
  );
25056
25520
  await server.connect(transport);
25057
25521
  const sessionId = transport.sessionId;
25058
- sseSessions.set(sessionId, { transport, server, createdAt: Date.now() });
25522
+ session.transport = transport;
25523
+ session.server = server;
25524
+ sseSessions.set(sessionId, session);
25059
25525
  transport.onclose = () => {
25060
25526
  sseSessions.delete(sessionId);
25061
25527
  server.close().catch(() => {
@@ -25065,6 +25531,58 @@ async function handleSse(c, resourcePath) {
25065
25531
  }
25066
25532
  app.get("/sse", (c) => handleSse(c, "/sse"));
25067
25533
  app.get("/fr/sse", (c) => handleSse(c, "/fr/sse"));
25534
+ function scheduleSseTelemetryRefresh(session, stampSeqAtMessageStart, timeoutMs = IDENTITY_RESOLVE_TIMEOUT_MS) {
25535
+ if (session.refreshing) return;
25536
+ session.refreshing = true;
25537
+ session.refreshPending = true;
25538
+ const epoch = ++session.refreshEpoch;
25539
+ let readSettled = false;
25540
+ let timedOut = false;
25541
+ const applyIfCurrent = (apply, releaseGuard) => {
25542
+ if (session.refreshEpoch !== epoch) return false;
25543
+ session.refreshEpoch++;
25544
+ apply();
25545
+ session.refreshPending = false;
25546
+ if (releaseGuard) session.refreshing = false;
25547
+ return true;
25548
+ };
25549
+ const failClosed = () => {
25550
+ session.suppressed = true;
25551
+ session.forceClosed = true;
25552
+ session.client.clearTelemetryStampOrigin(stampSeqAtMessageStart);
25553
+ };
25554
+ const timeout = setTimeout(() => {
25555
+ if (readSettled) return;
25556
+ timedOut = true;
25557
+ applyIfCurrent(failClosed, false);
25558
+ }, timeoutMs);
25559
+ void session.client.fetchTelemetryEnabled().then(
25560
+ (enabled) => {
25561
+ readSettled = true;
25562
+ clearTimeout(timeout);
25563
+ if (timedOut) {
25564
+ session.refreshing = false;
25565
+ return;
25566
+ }
25567
+ applyIfCurrent(() => {
25568
+ if (enabled === false) {
25569
+ session.client.clearTelemetryStampOrigin(stampSeqAtMessageStart);
25570
+ }
25571
+ session.suppressed = enabled === false;
25572
+ session.forceClosed = false;
25573
+ }, true);
25574
+ },
25575
+ () => {
25576
+ readSettled = true;
25577
+ clearTimeout(timeout);
25578
+ if (timedOut) {
25579
+ session.refreshing = false;
25580
+ return;
25581
+ }
25582
+ applyIfCurrent(failClosed, true);
25583
+ }
25584
+ );
25585
+ }
25068
25586
  app.post("/messages", async (c) => {
25069
25587
  const foreign = rejectForeignOrigin(c);
25070
25588
  if (foreign) return foreign;
@@ -25078,6 +25596,9 @@ app.post("/messages", async (c) => {
25078
25596
  }
25079
25597
  const env = c.env;
25080
25598
  const body = await c.req.json().catch(() => void 0);
25599
+ const stampSeqAtMessageStart = session.client.telemetryStampSeq();
25600
+ session.client.clearTelemetryStampOrigin(stampSeqAtMessageStart);
25601
+ scheduleSseTelemetryRefresh(session, stampSeqAtMessageStart);
25081
25602
  await session.transport.handlePostMessage(env.incoming, env.outgoing, body);
25082
25603
  return new Response(null, { headers: { "x-hono-already-sent": "1" } });
25083
25604
  });
@@ -25117,5 +25638,8 @@ if (isEntrypoint) {
25117
25638
  export {
25118
25639
  app,
25119
25640
  bindTelemetryIdentity,
25120
- resolveIdentity
25641
+ resolveIdentity,
25642
+ scheduleSseTelemetryRefresh,
25643
+ suppressTelemetry,
25644
+ telemetryHandleForRequest
25121
25645
  };