@leadbay/mcp 0.33.2 → 0.33.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,98 @@
1
1
  # Changelog — @leadbay/mcp
2
2
 
3
+ ## 0.33.4 — 2026-09-02
4
+
5
+ `leadbay_enrich_contacts` moves from `granularWriteTools` to
6
+ `compositeWriteTools` (product#4050).
7
+
8
+ It is the only tool that enriches ONE chosen person — `leadId` + `contactId`,
9
+ paid-candidate path first, org-contact path on `NOT_FOUND` — but it sat behind
10
+ `LEADBAY_MCP_ADVANCED=1`, which hosted never sets. No granular tool has been
11
+ called from a hosted IP in 30 days. The daily check-in prompt and the pull-leads
12
+ NEXT STEPS table already told the agent to call it, so on hosted they named a
13
+ tool that was not registered. `zoe+dogfood@leadbay.ai`'s scheduled agent
14
+ reached for `leadbay_pin_contact` instead (41 `contact not found` over 12 days;
15
+ see 0.33.3). Same registration pattern as add/remove/pin/unpin/update_contact
16
+ and set_lead_status: granular-shaped, lives in `tools/`, default write surface,
17
+ still hidden by `LEADBAY_MCP_WRITE=0`. Not in `COMPOSITE_FILE_TOOL_NAMES`, so
18
+ `_triggered_by` stays optional.
19
+
20
+ What moved with it, so the tool works on the surface it now lands on:
21
+
22
+ - **The result hint names a read that exists there.** It said "re-check
23
+ `leadbay_get_contacts`", which is advanced-only. It now says re-read the
24
+ lead's contacts via `leadbay_research_lead_by_id` (or `get_contacts` where
25
+ exposed).
26
+ - **The description carries routing.** `routing` + `rendering_hint`
27
+ frontmatter, added to `TOOLS_WITH_ROUTING`; the `prefer_when` states that a
28
+ `source:"paid"` candidate id from `research_lead_by_id` is valid input and
29
+ that pinning does not enrich anyone. Cross-routes to `enrich_titles`
30
+ (by title, many leads), `pin_contact`, `prepare_outreach`.
31
+ - **The pin/unpin 404 hint and `heuristics/pinnable-contacts` name it** as the
32
+ direct route alongside `enrich_titles` and `add_contact`. `enrich_titles`'s
33
+ WHEN NOT TO USE drops the "(granular)" label.
34
+
35
+ Tests: `packages/mcp/test/enrich-contacts-default-surface.test.ts` drives the
36
+ real MCP `tools/list` — present with `includeWrite:true, includeAdvanced:false`,
37
+ absent with `includeWrite:false`, and every `leadbay_*` the description and
38
+ the call hint name is itself registered on that surface.
39
+ `packages/core/test/unit/tools/pin-contact-hint-names-enrich-contacts.test.ts`
40
+ pins the hint.
41
+
42
+ ## 0.33.3 — 2026-09-02
43
+
44
+ `leadbay_pin_contact` failed 43 of its 48 production calls.
45
+
46
+ Measured over the 180 days to 2026-09-02 (PostHog `mcp tool called`,
47
+ `properties.ok = false`). Sentry issue `MCP-3A` carries exactly 43 events, so
48
+ every `contact not found` in the `mcp` project is a pin call. 41 of the 43 are
49
+ one scheduled agent, `zoe+dogfood@leadbay.ai`, across 12 days and MCP 0.26.0
50
+ through 0.33.2. Its own `triggered_by` reads *"tâche planifiée : épingler le DG
51
+ Mickael Hamot (CROMOLOGY SERVICES) à la place du président"*. It picks the DG
52
+ out of `leadbay_research_lead_by_id` and pins that id, which on an unenriched
53
+ company is a `source: "paid"` candidate. It has never once succeeded, and pin
54
+ is its third most used tool.
55
+
56
+ `POST /contacts/{id}/pin` resolves through `org_contacts` only
57
+ (`OrgContactRoutes.kt:44`), so a paid candidate id can never resolve there. Two
58
+ changes:
59
+
60
+ - **The 404 now names its cause.** The client's shared 404 hint is "Verify the
61
+ ID is correct" (`client.ts:1048`), which on this endpoint is false: the id is
62
+ correct, it is the wrong namespace. An agent reads that as "look it up and
63
+ retry" and does, in bursts of up to 10 calls in 13 seconds. pin and unpin now
64
+ catch `NOT_FOUND` and replace the hint with one that names the org-vs-paid
65
+ split, says not to retry, and names the tools that make the person pinnable.
66
+ - **The descriptions carry the rule.** New shared snippet
67
+ `snippets/heuristics/pinnable-contacts.md`, included by both templates, plus
68
+ the rule in `prefer_when` so it lands in the first 600 chars every host reads.
69
+ It also states that pinning does not steer enrichment: enrichment selects by
70
+ job title (`resolveAutoIncludedTitles` →
71
+ `paidContacts.findSimilarJobTitlesWithScores`) and `pinnedBy` plays no part.
72
+ That was the second half of the agent's mistake.
73
+
74
+ **`pinned` is now readable.** The backend's `ContactPayload` has carried
75
+ `pinned` + `pinned_by_ai` all along; every MCP shaping site dropped them, so a
76
+ pin could be written but not read back except by watching `recommended`, which
77
+ moves for other reasons too. Now passed through in `research_lead_by_id`,
78
+ `get_contacts` and `get_lead_profile`, and marked `📌` in both markdown contact
79
+ lists. Deliberately asymmetric: `PaidContactPayload` has no pin state, so paid
80
+ contacts get no `pinned` key rather than a synthetic `false`.
81
+
82
+ Verified live against FR staging, running the built branch:
83
+
84
+ ```
85
+ BEFORE PIN: org contact → pinned: false paid contacts → no pinned key
86
+ AFTER PIN: org contact → pinned: true (pin http 204)
87
+ AFTER UNPIN: org contact → pinned: false (unpin http 204)
88
+ ```
89
+
90
+ Remaining, not fixed here: on hosted an agent still has no way to enrich one
91
+ named person. `leadbay_enrich_contacts` takes a `contact_id` and does exactly
92
+ that, but sits in `granularWriteTools` behind `LEADBAY_MCP_ADVANCED=1`, and no
93
+ granular tool has been called from a hosted IP in 30 days. That is why the
94
+ agent reached for pin. Filed as leadbay/product#4050.
95
+
3
96
  ## 0.33.2 — 2026-09-02
4
97
 
5
98
  Editing one field on a contact erased the others (product#4046).
package/README.md CHANGED
@@ -634,7 +634,7 @@ Use `dry_run: true` to validate domain formatting and wizard reachability withou
634
634
 
635
635
  - Tokens live only in your MCP client's config file — they never traverse the network except to `api-{region}.leadbay.app`.
636
636
  - The `leadbay_login` tool from the OpenClaw adapter is **not** registered on MCP: exposing a credential-taking tool to an LLM is a prompt-injection risk. Use the token path above.
637
- - The `leadbay_add_note` tool is a write action flagged `optional: true`. If your client supports per-tool opt-in, leave it disabled until you need it. `leadbay_enrich_contacts` is a granular tool exposed only when `LEADBAY_MCP_ADVANCED=1`.
637
+ - The `leadbay_add_note` tool is a write action flagged `optional: true`. If your client supports per-tool opt-in, leave it disabled until you need it. `leadbay_enrich_contacts` (enrich one named contact) is on the default write surface since 0.33.4; it was advanced-only before.
638
638
 
639
639
  ### Privacy & telemetry
640
640
 
package/dist/bin.js CHANGED
@@ -2282,15 +2282,41 @@ WHEN NOT TO USE: as a default \u2014 answering with even a free-text reason give
2282
2282
 
2283
2283
  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\`.
2284
2284
  `;
2285
- leadbay_enrich_contacts = `Order email and/or phone enrichment for a specific contact. Tries the paid-contact path and falls back to the org-contact path on NOT_FOUND. Each email reveal and each phone reveal consumes QUOTA. Both \`email\` and \`phone\` default to \`true\` \u2014 a bare call enriches both channels.
2285
+ leadbay_enrich_contacts = `## WHEN TO USE
2286
2286
 
2287
- WHEN TO USE: when you have a specific \`contact_id\` (from leadbay_get_contacts) and want to enrich just that one.
2287
+ Trigger phrases: "enrich this contact", "get this person's email", "find <name>'s phone number", "reveal the email of <name> at <company>", "enrich the managing director, not the president", "enrichir le DG \xE0 la place du pr\xE9sident".
2288
2288
 
2289
- WHEN NOT TO USE: for bulk enrichment by job title across many leads \u2014 use leadbay_enrich_titles, which handles the selection lifecycle and returns a clean preview/launch flow.
2289
+ Do NOT use for: "enrich the CEOs / a job title across my leads" \u2192 \`leadbay_enrich_titles\`; "pin / mark this person as the priority contact" \u2192 \`leadbay_pin_contact\`; "draft an email / prepare outreach for this lead" \u2192 \`leadbay_prepare_outreach\`.
2290
+
2291
+ Prefer when: the user names ONE person on ONE company \u2014 pass the lead id + that contact's own id. A \`source:"paid"\` candidate id from leadbay_research_lead_by_id is valid input. Pinning does not enrich anyone.
2292
+
2293
+ Examples that SHOULD invoke this tool:
2294
+ - "Get me Jane Doe's email at Acme."
2295
+ - "Enrich the managing director at Cromology, not the president."
2296
+ - "Reveal the phone number for this contact."
2297
+
2298
+ Examples that should NOT invoke this tool (sound similar, route elsewhere):
2299
+ - "Enrich the CEOs across my top 10 leads."
2300
+ - "Pin Jane Doe as the main contact on Acme."
2301
+ - "Draft an outreach email for Acme."
2302
+
2303
+ ## RENDER (quick)
2304
+
2305
+ One line: "Enrichment started for <name> (<email / phone>)". Then re-read the
2306
+ lead's contacts every ~30s via leadbay_research_lead_by_id and report when
2307
+ the requested channel has landed. Never print a credits figure.
2308
+
2309
+ ---
2310
+
2311
+ Order email and/or phone enrichment for a specific contact. Tries the paid-contact path and falls back to the org-contact path on NOT_FOUND. Each email reveal and each phone reveal consumes QUOTA. Both \`email\` and \`phone\` default to \`true\` \u2014 a bare call enriches both channels.
2312
+
2313
+ WHEN TO USE: when the user has already picked WHO they want on a company and you hold that person's \`contact_id\` \u2014 from \`leadbay_research_lead_by_id\` (\`reachable\` / \`candidates\` lists) or \`leadbay_get_contacts\` where exposed. A \`source: "paid"\` candidate id is the normal input here: it is the person Leadbay suggested but has not resolved yet, and this tool is what resolves them. What to expect on the re-read depends on which id you passed. **Paid candidate:** the candidate row never carries email or phone; when the provider finds a channel, the person appears as a \`source: "org"\` row (new, or merged into an existing org contact for the same person) with a DIFFERENT id, and that row holds the channel and is pinnable. If nothing is found, the candidate row reads \`enrichment_done: true\` with no channel and no org row is created. **Org contact** (\`source: "org"\`, e.g. adding phone to someone who already has an email): the SAME row is updated in place, same id.
2314
+
2315
+ WHEN NOT TO USE: for bulk enrichment by job title across many leads \u2014 use leadbay_enrich_titles, which handles the selection lifecycle and returns a clean preview/launch flow. Not to mark someone as the priority contact \u2014 that is leadbay_pin_contact, and pinning does not enrich anyone.
2290
2316
 
2291
2317
  ## QUOTA, NOT CREDITS
2292
2318
 
2293
- Enrichment is gated by QUOTA (the per-window allowance in \`leadbay_account_status\`), not a credit balance. **Never pre-refuse because a credit number looks low or zero** \u2014 a freemium/fresh account with quota left can enrich even when its credit counter reads 0. The reveal either fits the remaining quota or the backend returns 429 (\`quota_exceeded\`); only THEN surface the exhausted window + wait-or-top-up choice. The \`credits_remaining\` field on the result is **advisory internal context only \u2014 do NOT display it**. Because it can read \`0\` on an account that still has quota, printing \`_(N credits remaining)_\` would falsely tell the user they're out. Do not render a credits balance at all; if the user asks where they stand, call \`leadbay_account_status\` and show the quota gauge instead. The actual per-contact cost (\`enrichment.credits_used\`) appears on the contact via leadbay_get_contacts after enrichment.
2319
+ Enrichment is gated by QUOTA (the per-window allowance in \`leadbay_account_status\`), not a credit balance. **Never pre-refuse because a credit number looks low or zero** \u2014 a freemium/fresh account with quota left can enrich even when its credit counter reads 0. The reveal either fits the remaining quota or the backend returns 429 (\`quota_exceeded\`); only THEN surface the exhausted window + wait-or-top-up choice. The \`credits_remaining\` field on the result is **advisory internal context only \u2014 do NOT display it**. Because it can read \`0\` on an account that still has quota, printing \`_(N credits remaining)_\` would falsely tell the user they're out. Do not render a credits balance at all; if the user asks where they stand, call \`leadbay_account_status\` and show the quota gauge instead. The actual per-contact cost (\`enrichment.credits_used\`) appears on the contact after enrichment.
2294
2320
 
2295
2321
  **Channels: when the user asks to enrich a contact without naming a channel, confirm scope via \`ask_user_input_v0\`** \u2014 \`"Enrich email only, or email + phone? (phone uses more quota)"\` \u2192 \`["Email only", "Email + phone"]\` \u2014 then pass the chosen \`email\`/\`phone\` flags. Skip the question only if they already said which channel(s) they want.
2296
2322
 
@@ -2300,7 +2326,7 @@ This tool MUTATES state. The caller (agent or human-in-the-loop) is responsible
2300
2326
 
2301
2327
  WHEN TO USE: as the agent's go-to enrichment entry point, immediately before proposing outreach.
2302
2328
 
2303
- WHEN NOT TO USE: to enrich a single contact \u2014 that's leadbay_enrich_contacts (granular). Speculatively, before the user has committed to outreaching \u2014 enrichment consumes quota. **NOT to add "titles" or "LinkedIn" to a list** \u2014 a contact's \`job_title\` and \`linkedin_page\` already ride on the contact record; they are FREE and need no enrichment. If the user asks for "title and LinkedIn only", read those fields directly (e.g. leadbay_get_contacts / leadbay_research_lead_by_id); do NOT launch a job here. This tool is strictly the email / phone reveal, which consumes quota.
2329
+ WHEN NOT TO USE: to enrich a single named contact \u2014 that's leadbay_enrich_contacts. Speculatively, before the user has committed to outreaching \u2014 enrichment consumes quota. **NOT to add "titles" or "LinkedIn" to a list** \u2014 a contact's \`job_title\` and \`linkedin_page\` already ride on the contact record; they are FREE and need no enrichment. If the user asks for "title and LinkedIn only", read those fields directly (e.g. leadbay_get_contacts / leadbay_research_lead_by_id); do NOT launch a job here. This tool is strictly the email / phone reveal, which consumes quota.
2304
2330
 
2305
2331
  ## ENRICHMENT CONSUMES QUOTA \u2014 the model to reason with
2306
2332
 
@@ -3688,7 +3714,7 @@ Trigger phrases: "pin this contact", "mark this person as priority", "make this
3688
3714
 
3689
3715
  Do NOT use for: "unpin / remove the pin" \u2192 \`leadbay_unpin_contact\`; "add a contact to this company" \u2192 \`leadbay_add_contact\`; "remove / delete this contact" \u2192 \`leadbay_remove_contact\`.
3690
3716
 
3691
- Prefer when: user wants ONE person flagged as the priority on a company \u2014 pass that contact's own \`contact_id\`
3717
+ Prefer when: user wants ONE person flagged as the priority on a company \u2014 pass that contact's own \`contact_id\`, and ONLY a \`source:"org"\` contact can be pinned (a \`source:"paid"\` candidate returns 'contact not found')
3692
3718
 
3693
3719
  Examples that SHOULD invoke this tool:
3694
3720
  - "Pin Jane Doe as the main contact on this company."
@@ -3710,9 +3736,24 @@ Pin a single contact on a company so it surfaces first as a priority / favourite
3710
3736
 
3711
3737
  Pass the contact's **own** \`contact_id\` (the \`id\` field on a contact object from \`leadbay_research_lead_by_id\` or a contacts list) \u2014 **not** the parent lead id.
3712
3738
 
3739
+ **Only \`source: "org"\` contacts are pinnable.** Every contact returned by \`leadbay_research_lead_by_id\` carries a \`source\` field, and the two sources are separate id namespaces on the backend:
3740
+
3741
+ - \`source: "org"\` \u2014 a row in your organization's own contact directory. Pinnable. Also carries \`pinned\` (true when someone has pinned it) and \`pinned_by_ai\` (true when Leadbay's AI pinned it rather than a human).
3742
+ - \`source: "paid"\` \u2014 an enrichment *candidate* (the \`candidates\` bucket): a person Leadbay suggests but has not yet resolved into your directory. NOT pinnable, and carries no \`pinned\` field at all.
3743
+
3744
+ Passing a \`source: "paid"\` id here returns **\`contact not found\`**. That is the expected answer for a candidate, not an outage and not a transient error: nothing is broken, the person is simply not an org contact yet. Do not retry, do not re-fetch the lead hoping for a different result, and do not tell the user that pinning is failing or unavailable.
3745
+
3746
+ To pin someone who is currently only a candidate, first make them an org contact:
3747
+
3748
+ - \`leadbay_enrich_contacts\` with the lead id + this candidate's id enriches exactly this person. \`leadbay_enrich_titles\` (or \`leadbay_prepare_outreach\` with \`enrich: true\`) does the same by job title. When the provider finds an email or phone, each writes a NEW org contact for that person (or merges into an existing one). It has a **different \`id\`** from the paid candidate, so re-read the contacts list afterwards and pin the \`source: "org"\` row. If nothing was found, no org contact exists and there is nothing to pin.
3749
+ - Or add them directly with \`leadbay_add_contact\`, which returns the new org contact's \`id\` \u2014 that id is pinnable immediately.
3750
+
3751
+ **Pinning does not steer enrichment.** It only marks who the priority is on a company the user already has. "Enrich the Directeur G\xE9n\xE9ral rather than the Pr\xE9sident" is \`leadbay_enrich_contacts\` with that person's id (or \`leadbay_enrich_titles\` with the wanted title) \u2014 not a pin. Pinning first and enriching after changes nothing about who gets enriched.
3752
+
3753
+
3713
3754
  Backend: \`POST /contacts/{contact_id}/pin\` \u2192 204. Idempotent. The inverse is \`leadbay_unpin_contact\`.
3714
3755
 
3715
- Returns \`{ pinned: true, contact_id, action: "pinned" }\`.
3756
+ Returns \`{ pinned: true, contact_id, action: "pinned" }\`. To read the resulting state back, re-call \`leadbay_research_lead_by_id\` \u2014 the pinned contact's \`pinned\` flips to \`true\` and it becomes the lead's \`recommended\` contact.
3716
3757
 
3717
3758
  Requires: LEADBAY_MCP_WRITE=1 (MCP) or exposeWrite=true (OpenClaw).
3718
3759
  `;
@@ -5657,7 +5698,7 @@ Trigger phrases: "unpin this contact", "remove the pin from this contact", "this
5657
5698
 
5658
5699
  Do NOT use for: "pin / mark as priority" \u2192 \`leadbay_pin_contact\`; "remove / delete this contact" \u2192 \`leadbay_remove_contact\`.
5659
5700
 
5660
- Prefer when: user wants to clear the pinned flag on a contact (but keep the contact) \u2014 pass that contact's own \`contact_id\`
5701
+ Prefer when: user wants to clear the pinned flag on a contact (but keep the contact) \u2014 pass that contact's own \`contact_id\`, and ONLY a \`source:"org"\` contact can be unpinned (a \`source:"paid"\` candidate returns 'contact not found')
5661
5702
 
5662
5703
  Examples that SHOULD invoke this tool:
5663
5704
  - "Unpin Jane Doe \u2014 she's not the priority anymore."
@@ -5680,9 +5721,26 @@ Unpin a single contact on a company \u2014 clears its priority / favourite flag.
5680
5721
 
5681
5722
  Pass the contact's **own** \`contact_id\` \u2014 not the parent lead id.
5682
5723
 
5724
+ **Only \`source: "org"\` contacts are pinnable.** Every contact returned by \`leadbay_research_lead_by_id\` carries a \`source\` field, and the two sources are separate id namespaces on the backend:
5725
+
5726
+ - \`source: "org"\` \u2014 a row in your organization's own contact directory. Pinnable. Also carries \`pinned\` (true when someone has pinned it) and \`pinned_by_ai\` (true when Leadbay's AI pinned it rather than a human).
5727
+ - \`source: "paid"\` \u2014 an enrichment *candidate* (the \`candidates\` bucket): a person Leadbay suggests but has not yet resolved into your directory. NOT pinnable, and carries no \`pinned\` field at all.
5728
+
5729
+ Passing a \`source: "paid"\` id here returns **\`contact not found\`**. That is the expected answer for a candidate, not an outage and not a transient error: nothing is broken, the person is simply not an org contact yet. Do not retry, do not re-fetch the lead hoping for a different result, and do not tell the user that pinning is failing or unavailable.
5730
+
5731
+ To pin someone who is currently only a candidate, first make them an org contact:
5732
+
5733
+ - \`leadbay_enrich_contacts\` with the lead id + this candidate's id enriches exactly this person. \`leadbay_enrich_titles\` (or \`leadbay_prepare_outreach\` with \`enrich: true\`) does the same by job title. When the provider finds an email or phone, each writes a NEW org contact for that person (or merges into an existing one). It has a **different \`id\`** from the paid candidate, so re-read the contacts list afterwards and pin the \`source: "org"\` row. If nothing was found, no org contact exists and there is nothing to pin.
5734
+ - Or add them directly with \`leadbay_add_contact\`, which returns the new org contact's \`id\` \u2014 that id is pinnable immediately.
5735
+
5736
+ **Pinning does not steer enrichment.** It only marks who the priority is on a company the user already has. "Enrich the Directeur G\xE9n\xE9ral rather than the Pr\xE9sident" is \`leadbay_enrich_contacts\` with that person's id (or \`leadbay_enrich_titles\` with the wanted title) \u2014 not a pin. Pinning first and enriching after changes nothing about who gets enriched.
5737
+
5738
+
5739
+ A \`source: "org"\` contact that was never pinned is a no-op here, not an error \u2014 the backend answers 204 either way. Check \`pinned\` on the contact before calling if you need to tell the user whether anything actually changed.
5740
+
5683
5741
  Backend: \`POST /contacts/{contact_id}/unpin\` \u2192 204. Idempotent. The inverse is \`leadbay_pin_contact\`.
5684
5742
 
5685
- Returns \`{ pinned: false, contact_id, action: "unpinned" }\`.
5743
+ Returns \`{ pinned: false, contact_id, action: "unpinned" }\`. To read the resulting state back, re-call \`leadbay_research_lead_by_id\` \u2014 the contact's \`pinned\` flips to \`false\` and the lead's \`recommended\` contact reverts to the title-matched default.
5686
5744
 
5687
5745
  Requires: LEADBAY_MCP_WRITE=1 (MCP) or exposeWrite=true (OpenClaw).
5688
5746
  `;
@@ -6086,6 +6144,10 @@ var init_get_lead_profile = __esm({
6086
6144
  job_title: c.job_title,
6087
6145
  recommended: c.recommended,
6088
6146
  enrichment: c.enrichment,
6147
+ // Org contacts only — the backend's PaidContactPayload carries no pin
6148
+ // state, because a paid candidate cannot be pinned.
6149
+ pinned: c.pinned ?? false,
6150
+ pinned_by_ai: c.pinned_by_ai ?? false,
6089
6151
  source: "org"
6090
6152
  })),
6091
6153
  ...paidContacts.map((c) => ({
@@ -6177,7 +6239,7 @@ var init_get_contacts = __esm({
6177
6239
  properties: {
6178
6240
  contacts: {
6179
6241
  type: "array",
6180
- description: "Merged org+paid contacts. Each: {id, first_name, last_name, email, phone_number, linkedin_page, job_title, recommended, enrichment, source:'org'|'paid'}.",
6242
+ description: "Merged org+paid contacts. Each: {id, first_name, last_name, email, phone_number, linkedin_page, job_title, recommended, enrichment, source:'org'|'paid'}. `source:'org'` entries additionally carry {pinned, pinned_by_ai}; `source:'paid'` entries do not, because a paid candidate cannot be pinned \u2014 passing its id to leadbay_pin_contact / leadbay_unpin_contact returns NOT_FOUND.",
6181
6243
  items: {
6182
6244
  type: "object",
6183
6245
  properties: {
@@ -6189,6 +6251,14 @@ var init_get_contacts = __esm({
6189
6251
  linkedin_page: { type: ["string", "null"] },
6190
6252
  job_title: { type: ["string", "null"] },
6191
6253
  recommended: { type: "boolean" },
6254
+ pinned: {
6255
+ type: "boolean",
6256
+ description: "Someone flagged this person as the priority on the company. Present on source:'org' contacts only."
6257
+ },
6258
+ pinned_by_ai: {
6259
+ type: "boolean",
6260
+ description: "The pin came from Leadbay's AI rather than a human. Present on source:'org' contacts only."
6261
+ },
6192
6262
  source: { type: "string", enum: ["org", "paid"] },
6193
6263
  enrichment: {
6194
6264
  type: ["object", "null"],
@@ -6247,6 +6317,10 @@ var init_get_contacts = __esm({
6247
6317
  job_title: c.job_title,
6248
6318
  recommended: c.recommended,
6249
6319
  enrichment: c.enrichment,
6320
+ // Org contacts only — the backend's PaidContactPayload carries no
6321
+ // pin state, because a paid candidate cannot be pinned.
6322
+ pinned: c.pinned ?? false,
6323
+ pinned_by_ai: c.pinned_by_ai ?? false,
6250
6324
  source: "org"
6251
6325
  })),
6252
6326
  ...paidContacts.map((c) => ({
@@ -6558,7 +6632,7 @@ var init_enrich_contacts = __esm({
6558
6632
  email_requested: email,
6559
6633
  phone_requested: phone,
6560
6634
  credits_remaining: creditsRemaining,
6561
- hint: "Enrichment started (runs async). Re-check leadbay_get_contacts every ~30s and treat the contact as done only when the REQUESTED channel actually landed \u2014 " + [email ? "`email` present" : null, phone ? "`phone_number` present" : null].filter(Boolean).join(" AND ") + " (contact.enrichment.done alone is NOT enough: a contact previously enriched for the other channel already shows done:true, so a phone reveal can be reported before phone_number arrives). Then report the result \u2014 don't end your turn leaving it for the user to reprompt. Bound the wait: if the requested field hasn't landed after ~90s\u20132min of spaced re-checks, it likely can't be resolved \u2014 report that plainly (naming the channel that didn't resolve) rather than polling forever. EXCEPTION: if the user explicitly asked NOT to wait (background/'I'll check later'), don't run this loop \u2014 hand back the leadId + contactId and tell them to ask again / re-check later."
6635
+ hint: "Enrichment started (runs async). Re-read the lead's contacts every ~30s (leadbay_research_lead_by_id; or leadbay_get_contacts where exposed) and treat the contact as done only when the REQUESTED channel actually landed \u2014 " + [email ? "`email` present" : null, phone ? "`phone_number` present" : null].filter(Boolean).join(" AND ") + " \u2014 on the person's row: for a `source:\"paid\"` candidate that is a NEW `source:\"org\"` row with a different id (the candidate row itself never carries a channel and only flips enrichment_done; done with no org row means nothing was found), for a `source:\"org\"` contact it is the same row (contact.enrichment.done alone is NOT enough: a contact previously enriched for the other channel already shows done:true, so a phone reveal can be reported before phone_number arrives). Then report the result \u2014 don't end your turn leaving it for the user to reprompt. Bound the wait: if the requested field hasn't landed after ~90s\u20132min of spaced re-checks, it likely can't be resolved \u2014 report that plainly (naming the channel that didn't resolve) rather than polling forever. EXCEPTION: if the user explicitly asked NOT to wait (background/'I'll check later'), don't run this loop \u2014 hand back the leadId + contactId and tell them to ask again / re-check later."
6562
6636
  };
6563
6637
  }
6564
6638
  };
@@ -11785,11 +11859,12 @@ var init_remove_contact = __esm({
11785
11859
  });
11786
11860
 
11787
11861
  // ../core/dist/tools/pin-contact.js
11788
- var pinContact;
11862
+ var NOT_PINNABLE_HINT, pinContact;
11789
11863
  var init_pin_contact = __esm({
11790
11864
  "../core/dist/tools/pin-contact.js"() {
11791
11865
  "use strict";
11792
11866
  init_tool_descriptions_generated();
11867
+ NOT_PINNABLE_HINT = 'This contact id is not in your organization\'s contact directory, so it cannot be pinned or unpinned. Almost always it is a `source: "paid"` enrichment candidate from leadbay_research_lead_by_id\'s `candidates` list; only `source: "org"` contacts are pinnable. The id is not wrong and the tool is not broken, so do NOT retry it. To act on this person, enrich them directly with leadbay_enrich_contacts (the lead id + this contact id), enrich by job title with leadbay_enrich_titles, or add them with leadbay_add_contact \u2014 each produces a NEW org contact with a different id, which is pinnable. Note that pinning does not decide who gets enriched: leadbay_enrich_titles selects people by job title, leadbay_enrich_contacts by the contact id you pass.';
11793
11868
  pinContact = {
11794
11869
  name: "leadbay_pin_contact",
11795
11870
  description: leadbay_pin_contact,
@@ -11813,7 +11888,13 @@ var init_pin_contact = __esm({
11813
11888
  additionalProperties: false
11814
11889
  },
11815
11890
  execute: async (client, params, _ctx) => {
11816
- await client.requestVoid("POST", `/contacts/${params.contact_id}/pin`);
11891
+ try {
11892
+ await client.requestVoid("POST", `/contacts/${params.contact_id}/pin`);
11893
+ } catch (e) {
11894
+ if (e?.code === "NOT_FOUND")
11895
+ throw { ...e, hint: NOT_PINNABLE_HINT };
11896
+ throw e;
11897
+ }
11817
11898
  return { pinned: true, contact_id: params.contact_id, action: "pinned" };
11818
11899
  }
11819
11900
  };
@@ -11826,6 +11907,7 @@ var init_unpin_contact = __esm({
11826
11907
  "../core/dist/tools/unpin-contact.js"() {
11827
11908
  "use strict";
11828
11909
  init_tool_descriptions_generated();
11910
+ init_pin_contact();
11829
11911
  unpinContact = {
11830
11912
  name: "leadbay_unpin_contact",
11831
11913
  description: leadbay_unpin_contact,
@@ -11849,7 +11931,13 @@ var init_unpin_contact = __esm({
11849
11931
  additionalProperties: false
11850
11932
  },
11851
11933
  execute: async (client, params, _ctx) => {
11852
- await client.requestVoid("POST", `/contacts/${params.contact_id}/unpin`);
11934
+ try {
11935
+ await client.requestVoid("POST", `/contacts/${params.contact_id}/unpin`);
11936
+ } catch (e) {
11937
+ if (e?.code === "NOT_FOUND")
11938
+ throw { ...e, hint: NOT_PINNABLE_HINT };
11939
+ throw e;
11940
+ }
11853
11941
  return { pinned: false, contact_id: params.contact_id, action: "unpinned" };
11854
11942
  }
11855
11943
  };
@@ -13965,7 +14053,8 @@ ${firm.short_description}`);
13965
14053
  const ln = c.last_name ?? "";
13966
14054
  const title = c.job_title ?? "\u2014";
13967
14055
  const channel = c.email ?? c.phone_number ?? "\u2014";
13968
- out.push(`- **${(fn + " " + ln).trim() || "(unknown)"}** \u2014 ${title} \xB7 ${channel}`);
14056
+ const pin = c.pinned ? " \u{1F4CC}" : "";
14057
+ out.push(`- **${(fn + " " + ln).trim() || "(unknown)"}**${pin} \u2014 ${title} \xB7 ${channel}`);
13969
14058
  }
13970
14059
  }
13971
14060
  const candidates = Array.isArray(contacts.candidates) ? contacts.candidates : [];
@@ -13977,7 +14066,8 @@ ${firm.short_description}`);
13977
14066
  const ln = c.last_name ?? "";
13978
14067
  const title = c.job_title ?? "\u2014";
13979
14068
  const li = c.linkedin_page ? `LinkedIn` : "no LinkedIn";
13980
- out.push(`- **${(fn + " " + ln).trim() || "(unknown)"}** \u2014 ${title} \xB7 ${li}`);
14069
+ const pin = c.pinned ? " \u{1F4CC}" : "";
14070
+ out.push(`- **${(fn + " " + ln).trim() || "(unknown)"}**${pin} \u2014 ${title} \xB7 ${li}`);
13981
14071
  }
13982
14072
  if (candidates.length > 10)
13983
14073
  out.push(`- _${candidates.length - 10} more \u2026_`);
@@ -14189,7 +14279,7 @@ var init_research_lead_by_id = __esm({
14189
14279
  },
14190
14280
  contacts: {
14191
14281
  type: "object",
14192
- description: 'Two-tier contact set, partitioned by reachability \u2014 agent-friendly framing of the backend\'s paid-vs-org split. `reachable`: contacts with an email or phone right now (org-directory entries that ship with channels, PLUS paid contacts whose enrichment has completed). The agent can message these without buying enrichment. `candidates`: paid-contact entries WITHOUT resolved channels yet \u2014 typically LinkedIn URL only, `enrichment_done: false`. The agent must call leadbay_enrich_titles (or leadbay_prepare_outreach with enrich:true) before these become messagable. Every contact in both lists carries `source`: `"org"` means it is a row in your organization\'s own contact directory, `"paid"` means it came from enrichment. The two are separate id namespaces on the backend, so only a `source:"org"` id can be passed to leadbay_update_contact / leadbay_remove_contact \u2014 a `"paid"` id returns NOT_FOUND there.',
14282
+ description: 'Two-tier contact set, partitioned by reachability \u2014 agent-friendly framing of the backend\'s paid-vs-org split. `reachable`: contacts with an email or phone right now (org-directory entries that ship with channels, PLUS paid contacts whose enrichment has completed). The agent can message these without buying enrichment. `candidates`: paid-contact entries WITHOUT resolved channels yet \u2014 typically LinkedIn URL only, `enrichment_done: false`. The agent must call leadbay_enrich_titles (or leadbay_prepare_outreach with enrich:true) before these become messagable. Every contact in both lists carries `source`: `"org"` means it is a row in your organization\'s own contact directory, `"paid"` means it came from enrichment. The two are separate id namespaces on the backend, so only a `source:"org"` id can be passed to leadbay_update_contact / leadbay_remove_contact / leadbay_pin_contact / leadbay_unpin_contact \u2014 a `"paid"` id returns NOT_FOUND there, which means "this candidate is not an org contact yet", NOT that the tool is broken. Only `source:"org"` contacts carry `pinned` (someone flagged this person as the priority) and `pinned_by_ai` (that someone was Leadbay\'s AI, not a human); paid candidates have no pin state because they cannot be pinned.',
14193
14283
  properties: {
14194
14284
  reachable: { type: "array", items: { type: "object" } },
14195
14285
  candidates: { type: "array", items: { type: "object" } }
@@ -14314,6 +14404,12 @@ var init_research_lead_by_id = __esm({
14314
14404
  linkedin_page: normalizeLinkedinPage5(c.linkedin_page ?? null),
14315
14405
  recommended: c.recommended,
14316
14406
  enrichment_done: true,
14407
+ // Pin state exists on org contacts only — mirror the backend, which
14408
+ // omits it entirely from PaidContactPayload. `pinned` is what makes a
14409
+ // pin readable at all; without it the agent can only infer the pin from
14410
+ // `recommended`, which also moves for non-pin reasons.
14411
+ pinned: c.pinned ?? false,
14412
+ pinned_by_ai: c.pinned_by_ai ?? false,
14317
14413
  source: "org"
14318
14414
  });
14319
14415
  const allContacts = [
@@ -21846,7 +21942,6 @@ var init_dist = __esm({
21846
21942
  ];
21847
21943
  granularWriteTools = [
21848
21944
  qualifyLead,
21849
- enrichContacts,
21850
21945
  addNote,
21851
21946
  selectLeads,
21852
21947
  deselectLeads,
@@ -21998,6 +22093,12 @@ var init_dist = __esm({
21998
22093
  pinContact,
21999
22094
  unpinContact,
22000
22095
  updateContact,
22096
+ // enrichContacts is granular-shaped (one POST per contact) but registered
22097
+ // HERE, not in granularWriteTools: it is the only tool that enriches ONE
22098
+ // chosen person (leadId + contactId) rather than a job title, and hosted
22099
+ // never sets LEADBAY_MCP_ADVANCED, so behind that gate no hosted agent could
22100
+ // act on a person it had already identified (product#4050).
22101
+ enrichContacts,
22001
22102
  // createCustomField is granular-shaped but file-import prompts depend on it
22002
22103
  // to preserve source-system links without requiring advanced-tool exposure.
22003
22104
  createCustomField,
@@ -25625,7 +25726,7 @@ var FRICTION = `Problem reports: when the user asks you to report a Leadbay prob
25625
25726
  var MENTAL_MODEL = `How Leadbay works (mental model): Leadbay is a sales inbox, not a queryable database. Each day the user logs back in, a fresh batch of leads is delivered. Batch size is paced by how many leads the user has actually acted on recently \u2014 some workflows produce a big stream of smaller prospects, others a narrow stream of bigger ones. Pulling more won't produce more; the user acting on leads (outreach, skips, saves) does.`;
25626
25727
  var QUOTA_TOPUP = `Quota & top-ups: when a tool returns QUOTA_EXCEEDED / 429, the user has TWO options \u2014 wait for the window reset (daily / weekly / monthly resets shown in leadbay_account_status), OR top up AI credits (top-ups clear the throttle IMMEDIATELY \u2014 they are not subject to the same window). Always offer BOTH options; default-recommending 'wait until tomorrow' is wrong when a 30-second top-up unblocks the same call. If the host exposes leadbay_create_topup_link, OFFER it on every quota wall: 'Want me to generate a top-up link?' \u2014 when the user says yes, call leadbay_create_topup_link and surface the returned Stripe URL as a clickable link for the user to open in their browser. (Sibling leadbay_open_billing_portal is for ongoing subscription changes, not one-shot top-ups.) AFTER the user has topped up: do NOT keep refusing operations. A top-up invalidates every prior 429 and every stale 'you're at your quota' snapshot. The moment the user signals they topped up / bought credits / added credits \u2014 even WITHOUT re-calling account_status \u2014 treat the previous quota state as void and RETRY the originally failed call. (Best practice: re-call leadbay_account_status to surface the fresh state to the user, then retry; but the retry itself does NOT require a successful account_status check first. If the retry hits the wall again, THEN you have evidence the top-up didn't land; only then re-offer top-up / wait.) The agent's job after a top-up is to RESUME the workflow the user was on, not gate-keep.
25627
25728
 
25628
- Show the refreshed quota AFTER a paid action has actually COMPLETED: when leadbay_bulk_enrich_status reports the job done \u2014 all_done, OR a plateau you've decided is terminal (overall_progress.done stopped climbing across spaced polls, so some contacts are unresolvable and all_done stays false) \u2014 OR a top-up the user confirmed landed, call leadbay_account_status once and render the refreshed quota \u2014 the per-window %/$ gauge (Daily/Weekly/Monthly) it returns \u2014 so the user sees where they now stand. Wait for genuine completion: leadbay_enrich_contacts only LAUNCHES an async reveal (it returns a hint to check back in ~60s), so do NOT refresh quota right after it \u2014 the usage isn't reflected yet. For that single-contact flow, refresh only once leadbay_get_contacts shows the REQUESTED channel actually landed \u2014 the requested email and/or phone_number present \u2014 NOT enrichment.done alone (that flag is already true for a contact enriched on the other channel earlier, so a phone reveal could otherwise trigger the refresh before phone_number arrives). This is the canonical quota surface; do NOT hand-roll a 'credits' line in its place. Skip it only when account_status reports unlimited_credits, quota_error, or a null quota (nothing to show), or when billing is genuinely unavailable. Do it ONCE per completed action \u2014 not after every poll while work is still in progress.`;
25729
+ Show the refreshed quota AFTER a paid action has actually COMPLETED: when leadbay_bulk_enrich_status reports the job done \u2014 all_done, OR a plateau you've decided is terminal (overall_progress.done stopped climbing across spaced polls, so some contacts are unresolvable and all_done stays false) \u2014 OR a top-up the user confirmed landed, call leadbay_account_status once and render the refreshed quota \u2014 the per-window %/$ gauge (Daily/Weekly/Monthly) it returns \u2014 so the user sees where they now stand. Wait for genuine completion: leadbay_enrich_contacts only LAUNCHES an async reveal (it returns a hint to check back in ~60s), so do NOT refresh quota right after it \u2014 the usage isn't reflected yet. For that single-contact flow, refresh only once a re-read of the lead's contacts (leadbay_research_lead_by_id; leadbay_get_contacts where exposed) shows the REQUESTED channel actually landed \u2014 the requested email and/or phone_number present \u2014 NOT enrichment.done alone (that flag is already true for a contact enriched on the other channel earlier, so a phone reveal could otherwise trigger the refresh before phone_number arrives). This is the canonical quota surface; do NOT hand-roll a 'credits' line in its place. Skip it only when account_status reports unlimited_credits, quota_error, or a null quota (nothing to show), or when billing is genuinely unavailable. Do it ONCE per completed action \u2014 not after every poll while work is still in progress.`;
25629
25730
  var TRANSIENT_401 = `Transient 401s (DO NOT narrate as an auth problem): Leadbay bearer tokens do NOT expire on a timer, and the client already auto-retried this call once before surfacing it. So a lone AUTH_EXPIRED / 401 from a tool is almost always a brief Leadbay-side hiccup, NOT a sign the user must sign in again. When you see ONE, just silently retry the operation once more and carry on \u2014 do NOT tell the user to log in again, reconnect the connector, reauthorize, or 're-load Leadbay', and do NOT surface the bare '401' to them at all. Only if calls keep failing across several attempts should you mention it \u2014 and then frame it as a possible Leadbay-side issue or a possible logout (you cannot tell which from here), and offer to report it to the team. Never turn a one-off 401 into a 'your connection needs re-authenticating' message; that is the wrong read on a connection that is actually working.`;
25630
25731
  var TRIGGERED_BY = `Trigger provenance (MANDATORY): every Leadbay composite-tool call MUST carry a non-empty \`_triggered_by\` argument \u2014 the verbatim slice of the user's most recent message that this call is acting upon. Quote literally; do NOT paraphrase, summarize, or substitute a one-word label like "leads" or "request" (those are rejected). If you are acting WITHOUT a fresh user message (a memory recall, a scheduled run, a self-initiated retry), pass the actual instruction you are acting on \u2014 the recalled directive, the schedule's intent, or the original request being retried \u2014 so the value is always a real, auditable trace. Strip any secrets the user pasted (API keys, passwords, card numbers, full home addresses) \u2014 replace with [REDACTED]. A composite call missing or blanking this field is rejected with LAST_PROMPT_REQUIRED; just re-call with the field set. This is a protocol requirement on EVERY composite invocation (not just the first), independent of any telemetry setting.`;
25631
25732
  var VERIFICATION = `After every email, call, message, or meeting with a lead's contact, you MUST call leadbay_report_outreach with verification={source, ref} (gmail_message_id from the Gmail send, calendar_event_id from a booking, or user_confirmed='<the user's literal confirmation>'). Skipping or fabricating verification poisons the human team's pipeline.`;
@@ -27901,7 +28002,7 @@ var OAUTH_BASE_URLS = {
27901
28002
  fr: "https://staging.api.leadbay.app"
27902
28003
  }
27903
28004
  };
27904
- var VERSION = "0.33.2";
28005
+ var VERSION = "0.33.4";
27905
28006
  var HELP = `
27906
28007
  leadbay-mcp ${VERSION} \u2014 Leadbay Model Context Protocol server
27907
28008
 
@@ -4873,15 +4873,41 @@ WHEN NOT TO USE: as a default \u2014 answering with even a free-text reason give
4873
4873
 
4874
4874
  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\`.
4875
4875
  `;
4876
- var leadbay_enrich_contacts = `Order email and/or phone enrichment for a specific contact. Tries the paid-contact path and falls back to the org-contact path on NOT_FOUND. Each email reveal and each phone reveal consumes QUOTA. Both \`email\` and \`phone\` default to \`true\` \u2014 a bare call enriches both channels.
4876
+ var leadbay_enrich_contacts = `## WHEN TO USE
4877
4877
 
4878
- WHEN TO USE: when you have a specific \`contact_id\` (from leadbay_get_contacts) and want to enrich just that one.
4878
+ Trigger phrases: "enrich this contact", "get this person's email", "find <name>'s phone number", "reveal the email of <name> at <company>", "enrich the managing director, not the president", "enrichir le DG \xE0 la place du pr\xE9sident".
4879
4879
 
4880
- WHEN NOT TO USE: for bulk enrichment by job title across many leads \u2014 use leadbay_enrich_titles, which handles the selection lifecycle and returns a clean preview/launch flow.
4880
+ Do NOT use for: "enrich the CEOs / a job title across my leads" \u2192 \`leadbay_enrich_titles\`; "pin / mark this person as the priority contact" \u2192 \`leadbay_pin_contact\`; "draft an email / prepare outreach for this lead" \u2192 \`leadbay_prepare_outreach\`.
4881
+
4882
+ Prefer when: the user names ONE person on ONE company \u2014 pass the lead id + that contact's own id. A \`source:"paid"\` candidate id from leadbay_research_lead_by_id is valid input. Pinning does not enrich anyone.
4883
+
4884
+ Examples that SHOULD invoke this tool:
4885
+ - "Get me Jane Doe's email at Acme."
4886
+ - "Enrich the managing director at Cromology, not the president."
4887
+ - "Reveal the phone number for this contact."
4888
+
4889
+ Examples that should NOT invoke this tool (sound similar, route elsewhere):
4890
+ - "Enrich the CEOs across my top 10 leads."
4891
+ - "Pin Jane Doe as the main contact on Acme."
4892
+ - "Draft an outreach email for Acme."
4893
+
4894
+ ## RENDER (quick)
4895
+
4896
+ One line: "Enrichment started for <name> (<email / phone>)". Then re-read the
4897
+ lead's contacts every ~30s via leadbay_research_lead_by_id and report when
4898
+ the requested channel has landed. Never print a credits figure.
4899
+
4900
+ ---
4901
+
4902
+ Order email and/or phone enrichment for a specific contact. Tries the paid-contact path and falls back to the org-contact path on NOT_FOUND. Each email reveal and each phone reveal consumes QUOTA. Both \`email\` and \`phone\` default to \`true\` \u2014 a bare call enriches both channels.
4903
+
4904
+ WHEN TO USE: when the user has already picked WHO they want on a company and you hold that person's \`contact_id\` \u2014 from \`leadbay_research_lead_by_id\` (\`reachable\` / \`candidates\` lists) or \`leadbay_get_contacts\` where exposed. A \`source: "paid"\` candidate id is the normal input here: it is the person Leadbay suggested but has not resolved yet, and this tool is what resolves them. What to expect on the re-read depends on which id you passed. **Paid candidate:** the candidate row never carries email or phone; when the provider finds a channel, the person appears as a \`source: "org"\` row (new, or merged into an existing org contact for the same person) with a DIFFERENT id, and that row holds the channel and is pinnable. If nothing is found, the candidate row reads \`enrichment_done: true\` with no channel and no org row is created. **Org contact** (\`source: "org"\`, e.g. adding phone to someone who already has an email): the SAME row is updated in place, same id.
4905
+
4906
+ WHEN NOT TO USE: for bulk enrichment by job title across many leads \u2014 use leadbay_enrich_titles, which handles the selection lifecycle and returns a clean preview/launch flow. Not to mark someone as the priority contact \u2014 that is leadbay_pin_contact, and pinning does not enrich anyone.
4881
4907
 
4882
4908
  ## QUOTA, NOT CREDITS
4883
4909
 
4884
- Enrichment is gated by QUOTA (the per-window allowance in \`leadbay_account_status\`), not a credit balance. **Never pre-refuse because a credit number looks low or zero** \u2014 a freemium/fresh account with quota left can enrich even when its credit counter reads 0. The reveal either fits the remaining quota or the backend returns 429 (\`quota_exceeded\`); only THEN surface the exhausted window + wait-or-top-up choice. The \`credits_remaining\` field on the result is **advisory internal context only \u2014 do NOT display it**. Because it can read \`0\` on an account that still has quota, printing \`_(N credits remaining)_\` would falsely tell the user they're out. Do not render a credits balance at all; if the user asks where they stand, call \`leadbay_account_status\` and show the quota gauge instead. The actual per-contact cost (\`enrichment.credits_used\`) appears on the contact via leadbay_get_contacts after enrichment.
4910
+ Enrichment is gated by QUOTA (the per-window allowance in \`leadbay_account_status\`), not a credit balance. **Never pre-refuse because a credit number looks low or zero** \u2014 a freemium/fresh account with quota left can enrich even when its credit counter reads 0. The reveal either fits the remaining quota or the backend returns 429 (\`quota_exceeded\`); only THEN surface the exhausted window + wait-or-top-up choice. The \`credits_remaining\` field on the result is **advisory internal context only \u2014 do NOT display it**. Because it can read \`0\` on an account that still has quota, printing \`_(N credits remaining)_\` would falsely tell the user they're out. Do not render a credits balance at all; if the user asks where they stand, call \`leadbay_account_status\` and show the quota gauge instead. The actual per-contact cost (\`enrichment.credits_used\`) appears on the contact after enrichment.
4885
4911
 
4886
4912
  **Channels: when the user asks to enrich a contact without naming a channel, confirm scope via \`ask_user_input_v0\`** \u2014 \`"Enrich email only, or email + phone? (phone uses more quota)"\` \u2192 \`["Email only", "Email + phone"]\` \u2014 then pass the chosen \`email\`/\`phone\` flags. Skip the question only if they already said which channel(s) they want.
4887
4913
 
@@ -4891,7 +4917,7 @@ var leadbay_enrich_titles = `Order contact enrichments by job title across many
4891
4917
 
4892
4918
  WHEN TO USE: as the agent's go-to enrichment entry point, immediately before proposing outreach.
4893
4919
 
4894
- WHEN NOT TO USE: to enrich a single contact \u2014 that's leadbay_enrich_contacts (granular). Speculatively, before the user has committed to outreaching \u2014 enrichment consumes quota. **NOT to add "titles" or "LinkedIn" to a list** \u2014 a contact's \`job_title\` and \`linkedin_page\` already ride on the contact record; they are FREE and need no enrichment. If the user asks for "title and LinkedIn only", read those fields directly (e.g. leadbay_get_contacts / leadbay_research_lead_by_id); do NOT launch a job here. This tool is strictly the email / phone reveal, which consumes quota.
4920
+ WHEN NOT TO USE: to enrich a single named contact \u2014 that's leadbay_enrich_contacts. Speculatively, before the user has committed to outreaching \u2014 enrichment consumes quota. **NOT to add "titles" or "LinkedIn" to a list** \u2014 a contact's \`job_title\` and \`linkedin_page\` already ride on the contact record; they are FREE and need no enrichment. If the user asks for "title and LinkedIn only", read those fields directly (e.g. leadbay_get_contacts / leadbay_research_lead_by_id); do NOT launch a job here. This tool is strictly the email / phone reveal, which consumes quota.
4895
4921
 
4896
4922
  ## ENRICHMENT CONSUMES QUOTA \u2014 the model to reason with
4897
4923
 
@@ -6279,7 +6305,7 @@ Trigger phrases: "pin this contact", "mark this person as priority", "make this
6279
6305
 
6280
6306
  Do NOT use for: "unpin / remove the pin" \u2192 \`leadbay_unpin_contact\`; "add a contact to this company" \u2192 \`leadbay_add_contact\`; "remove / delete this contact" \u2192 \`leadbay_remove_contact\`.
6281
6307
 
6282
- Prefer when: user wants ONE person flagged as the priority on a company \u2014 pass that contact's own \`contact_id\`
6308
+ Prefer when: user wants ONE person flagged as the priority on a company \u2014 pass that contact's own \`contact_id\`, and ONLY a \`source:"org"\` contact can be pinned (a \`source:"paid"\` candidate returns 'contact not found')
6283
6309
 
6284
6310
  Examples that SHOULD invoke this tool:
6285
6311
  - "Pin Jane Doe as the main contact on this company."
@@ -6301,9 +6327,24 @@ Pin a single contact on a company so it surfaces first as a priority / favourite
6301
6327
 
6302
6328
  Pass the contact's **own** \`contact_id\` (the \`id\` field on a contact object from \`leadbay_research_lead_by_id\` or a contacts list) \u2014 **not** the parent lead id.
6303
6329
 
6330
+ **Only \`source: "org"\` contacts are pinnable.** Every contact returned by \`leadbay_research_lead_by_id\` carries a \`source\` field, and the two sources are separate id namespaces on the backend:
6331
+
6332
+ - \`source: "org"\` \u2014 a row in your organization's own contact directory. Pinnable. Also carries \`pinned\` (true when someone has pinned it) and \`pinned_by_ai\` (true when Leadbay's AI pinned it rather than a human).
6333
+ - \`source: "paid"\` \u2014 an enrichment *candidate* (the \`candidates\` bucket): a person Leadbay suggests but has not yet resolved into your directory. NOT pinnable, and carries no \`pinned\` field at all.
6334
+
6335
+ Passing a \`source: "paid"\` id here returns **\`contact not found\`**. That is the expected answer for a candidate, not an outage and not a transient error: nothing is broken, the person is simply not an org contact yet. Do not retry, do not re-fetch the lead hoping for a different result, and do not tell the user that pinning is failing or unavailable.
6336
+
6337
+ To pin someone who is currently only a candidate, first make them an org contact:
6338
+
6339
+ - \`leadbay_enrich_contacts\` with the lead id + this candidate's id enriches exactly this person. \`leadbay_enrich_titles\` (or \`leadbay_prepare_outreach\` with \`enrich: true\`) does the same by job title. When the provider finds an email or phone, each writes a NEW org contact for that person (or merges into an existing one). It has a **different \`id\`** from the paid candidate, so re-read the contacts list afterwards and pin the \`source: "org"\` row. If nothing was found, no org contact exists and there is nothing to pin.
6340
+ - Or add them directly with \`leadbay_add_contact\`, which returns the new org contact's \`id\` \u2014 that id is pinnable immediately.
6341
+
6342
+ **Pinning does not steer enrichment.** It only marks who the priority is on a company the user already has. "Enrich the Directeur G\xE9n\xE9ral rather than the Pr\xE9sident" is \`leadbay_enrich_contacts\` with that person's id (or \`leadbay_enrich_titles\` with the wanted title) \u2014 not a pin. Pinning first and enriching after changes nothing about who gets enriched.
6343
+
6344
+
6304
6345
  Backend: \`POST /contacts/{contact_id}/pin\` \u2192 204. Idempotent. The inverse is \`leadbay_unpin_contact\`.
6305
6346
 
6306
- Returns \`{ pinned: true, contact_id, action: "pinned" }\`.
6347
+ Returns \`{ pinned: true, contact_id, action: "pinned" }\`. To read the resulting state back, re-call \`leadbay_research_lead_by_id\` \u2014 the pinned contact's \`pinned\` flips to \`true\` and it becomes the lead's \`recommended\` contact.
6307
6348
 
6308
6349
  Requires: LEADBAY_MCP_WRITE=1 (MCP) or exposeWrite=true (OpenClaw).
6309
6350
  `;
@@ -8248,7 +8289,7 @@ Trigger phrases: "unpin this contact", "remove the pin from this contact", "this
8248
8289
 
8249
8290
  Do NOT use for: "pin / mark as priority" \u2192 \`leadbay_pin_contact\`; "remove / delete this contact" \u2192 \`leadbay_remove_contact\`.
8250
8291
 
8251
- Prefer when: user wants to clear the pinned flag on a contact (but keep the contact) \u2014 pass that contact's own \`contact_id\`
8292
+ Prefer when: user wants to clear the pinned flag on a contact (but keep the contact) \u2014 pass that contact's own \`contact_id\`, and ONLY a \`source:"org"\` contact can be unpinned (a \`source:"paid"\` candidate returns 'contact not found')
8252
8293
 
8253
8294
  Examples that SHOULD invoke this tool:
8254
8295
  - "Unpin Jane Doe \u2014 she's not the priority anymore."
@@ -8271,9 +8312,26 @@ Unpin a single contact on a company \u2014 clears its priority / favourite flag.
8271
8312
 
8272
8313
  Pass the contact's **own** \`contact_id\` \u2014 not the parent lead id.
8273
8314
 
8315
+ **Only \`source: "org"\` contacts are pinnable.** Every contact returned by \`leadbay_research_lead_by_id\` carries a \`source\` field, and the two sources are separate id namespaces on the backend:
8316
+
8317
+ - \`source: "org"\` \u2014 a row in your organization's own contact directory. Pinnable. Also carries \`pinned\` (true when someone has pinned it) and \`pinned_by_ai\` (true when Leadbay's AI pinned it rather than a human).
8318
+ - \`source: "paid"\` \u2014 an enrichment *candidate* (the \`candidates\` bucket): a person Leadbay suggests but has not yet resolved into your directory. NOT pinnable, and carries no \`pinned\` field at all.
8319
+
8320
+ Passing a \`source: "paid"\` id here returns **\`contact not found\`**. That is the expected answer for a candidate, not an outage and not a transient error: nothing is broken, the person is simply not an org contact yet. Do not retry, do not re-fetch the lead hoping for a different result, and do not tell the user that pinning is failing or unavailable.
8321
+
8322
+ To pin someone who is currently only a candidate, first make them an org contact:
8323
+
8324
+ - \`leadbay_enrich_contacts\` with the lead id + this candidate's id enriches exactly this person. \`leadbay_enrich_titles\` (or \`leadbay_prepare_outreach\` with \`enrich: true\`) does the same by job title. When the provider finds an email or phone, each writes a NEW org contact for that person (or merges into an existing one). It has a **different \`id\`** from the paid candidate, so re-read the contacts list afterwards and pin the \`source: "org"\` row. If nothing was found, no org contact exists and there is nothing to pin.
8325
+ - Or add them directly with \`leadbay_add_contact\`, which returns the new org contact's \`id\` \u2014 that id is pinnable immediately.
8326
+
8327
+ **Pinning does not steer enrichment.** It only marks who the priority is on a company the user already has. "Enrich the Directeur G\xE9n\xE9ral rather than the Pr\xE9sident" is \`leadbay_enrich_contacts\` with that person's id (or \`leadbay_enrich_titles\` with the wanted title) \u2014 not a pin. Pinning first and enriching after changes nothing about who gets enriched.
8328
+
8329
+
8330
+ A \`source: "org"\` contact that was never pinned is a no-op here, not an error \u2014 the backend answers 204 either way. Check \`pinned\` on the contact before calling if you need to tell the user whether anything actually changed.
8331
+
8274
8332
  Backend: \`POST /contacts/{contact_id}/unpin\` \u2192 204. Idempotent. The inverse is \`leadbay_pin_contact\`.
8275
8333
 
8276
- Returns \`{ pinned: false, contact_id, action: "unpinned" }\`.
8334
+ Returns \`{ pinned: false, contact_id, action: "unpinned" }\`. To read the resulting state back, re-call \`leadbay_research_lead_by_id\` \u2014 the contact's \`pinned\` flips to \`false\` and the lead's \`recommended\` contact reverts to the title-matched default.
8277
8335
 
8278
8336
  Requires: LEADBAY_MCP_WRITE=1 (MCP) or exposeWrite=true (OpenClaw).
8279
8337
  `;
@@ -8648,6 +8706,10 @@ var getLeadProfile = {
8648
8706
  job_title: c.job_title,
8649
8707
  recommended: c.recommended,
8650
8708
  enrichment: c.enrichment,
8709
+ // Org contacts only — the backend's PaidContactPayload carries no pin
8710
+ // state, because a paid candidate cannot be pinned.
8711
+ pinned: c.pinned ?? false,
8712
+ pinned_by_ai: c.pinned_by_ai ?? false,
8651
8713
  source: "org"
8652
8714
  })),
8653
8715
  ...paidContacts.map((c) => ({
@@ -8732,7 +8794,7 @@ var getContacts = {
8732
8794
  properties: {
8733
8795
  contacts: {
8734
8796
  type: "array",
8735
- description: "Merged org+paid contacts. Each: {id, first_name, last_name, email, phone_number, linkedin_page, job_title, recommended, enrichment, source:'org'|'paid'}.",
8797
+ description: "Merged org+paid contacts. Each: {id, first_name, last_name, email, phone_number, linkedin_page, job_title, recommended, enrichment, source:'org'|'paid'}. `source:'org'` entries additionally carry {pinned, pinned_by_ai}; `source:'paid'` entries do not, because a paid candidate cannot be pinned \u2014 passing its id to leadbay_pin_contact / leadbay_unpin_contact returns NOT_FOUND.",
8736
8798
  items: {
8737
8799
  type: "object",
8738
8800
  properties: {
@@ -8744,6 +8806,14 @@ var getContacts = {
8744
8806
  linkedin_page: { type: ["string", "null"] },
8745
8807
  job_title: { type: ["string", "null"] },
8746
8808
  recommended: { type: "boolean" },
8809
+ pinned: {
8810
+ type: "boolean",
8811
+ description: "Someone flagged this person as the priority on the company. Present on source:'org' contacts only."
8812
+ },
8813
+ pinned_by_ai: {
8814
+ type: "boolean",
8815
+ description: "The pin came from Leadbay's AI rather than a human. Present on source:'org' contacts only."
8816
+ },
8747
8817
  source: { type: "string", enum: ["org", "paid"] },
8748
8818
  enrichment: {
8749
8819
  type: ["object", "null"],
@@ -8802,6 +8872,10 @@ var getContacts = {
8802
8872
  job_title: c.job_title,
8803
8873
  recommended: c.recommended,
8804
8874
  enrichment: c.enrichment,
8875
+ // Org contacts only — the backend's PaidContactPayload carries no
8876
+ // pin state, because a paid candidate cannot be pinned.
8877
+ pinned: c.pinned ?? false,
8878
+ pinned_by_ai: c.pinned_by_ai ?? false,
8805
8879
  source: "org"
8806
8880
  })),
8807
8881
  ...paidContacts.map((c) => ({
@@ -9078,7 +9152,7 @@ var enrichContacts = {
9078
9152
  email_requested: email,
9079
9153
  phone_requested: phone,
9080
9154
  credits_remaining: creditsRemaining,
9081
- hint: "Enrichment started (runs async). Re-check leadbay_get_contacts every ~30s and treat the contact as done only when the REQUESTED channel actually landed \u2014 " + [email ? "`email` present" : null, phone ? "`phone_number` present" : null].filter(Boolean).join(" AND ") + " (contact.enrichment.done alone is NOT enough: a contact previously enriched for the other channel already shows done:true, so a phone reveal can be reported before phone_number arrives). Then report the result \u2014 don't end your turn leaving it for the user to reprompt. Bound the wait: if the requested field hasn't landed after ~90s\u20132min of spaced re-checks, it likely can't be resolved \u2014 report that plainly (naming the channel that didn't resolve) rather than polling forever. EXCEPTION: if the user explicitly asked NOT to wait (background/'I'll check later'), don't run this loop \u2014 hand back the leadId + contactId and tell them to ask again / re-check later."
9155
+ hint: "Enrichment started (runs async). Re-read the lead's contacts every ~30s (leadbay_research_lead_by_id; or leadbay_get_contacts where exposed) and treat the contact as done only when the REQUESTED channel actually landed \u2014 " + [email ? "`email` present" : null, phone ? "`phone_number` present" : null].filter(Boolean).join(" AND ") + " \u2014 on the person's row: for a `source:\"paid\"` candidate that is a NEW `source:\"org\"` row with a different id (the candidate row itself never carries a channel and only flips enrichment_done; done with no org row means nothing was found), for a `source:\"org\"` contact it is the same row (contact.enrichment.done alone is NOT enough: a contact previously enriched for the other channel already shows done:true, so a phone reveal can be reported before phone_number arrives). Then report the result \u2014 don't end your turn leaving it for the user to reprompt. Bound the wait: if the requested field hasn't landed after ~90s\u20132min of spaced re-checks, it likely can't be resolved \u2014 report that plainly (naming the channel that didn't resolve) rather than polling forever. EXCEPTION: if the user explicitly asked NOT to wait (background/'I'll check later'), don't run this loop \u2014 hand back the leadId + contactId and tell them to ask again / re-check later."
9082
9156
  };
9083
9157
  }
9084
9158
  };
@@ -13931,6 +14005,7 @@ var removeContact = {
13931
14005
  };
13932
14006
 
13933
14007
  // ../core/dist/tools/pin-contact.js
14008
+ var NOT_PINNABLE_HINT = 'This contact id is not in your organization\'s contact directory, so it cannot be pinned or unpinned. Almost always it is a `source: "paid"` enrichment candidate from leadbay_research_lead_by_id\'s `candidates` list; only `source: "org"` contacts are pinnable. The id is not wrong and the tool is not broken, so do NOT retry it. To act on this person, enrich them directly with leadbay_enrich_contacts (the lead id + this contact id), enrich by job title with leadbay_enrich_titles, or add them with leadbay_add_contact \u2014 each produces a NEW org contact with a different id, which is pinnable. Note that pinning does not decide who gets enriched: leadbay_enrich_titles selects people by job title, leadbay_enrich_contacts by the contact id you pass.';
13934
14009
  var pinContact = {
13935
14010
  name: "leadbay_pin_contact",
13936
14011
  description: leadbay_pin_contact,
@@ -13954,7 +14029,13 @@ var pinContact = {
13954
14029
  additionalProperties: false
13955
14030
  },
13956
14031
  execute: async (client, params, _ctx) => {
13957
- await client.requestVoid("POST", `/contacts/${params.contact_id}/pin`);
14032
+ try {
14033
+ await client.requestVoid("POST", `/contacts/${params.contact_id}/pin`);
14034
+ } catch (e) {
14035
+ if (e?.code === "NOT_FOUND")
14036
+ throw { ...e, hint: NOT_PINNABLE_HINT };
14037
+ throw e;
14038
+ }
13958
14039
  return { pinned: true, contact_id: params.contact_id, action: "pinned" };
13959
14040
  }
13960
14041
  };
@@ -13983,7 +14064,13 @@ var unpinContact = {
13983
14064
  additionalProperties: false
13984
14065
  },
13985
14066
  execute: async (client, params, _ctx) => {
13986
- await client.requestVoid("POST", `/contacts/${params.contact_id}/unpin`);
14067
+ try {
14068
+ await client.requestVoid("POST", `/contacts/${params.contact_id}/unpin`);
14069
+ } catch (e) {
14070
+ if (e?.code === "NOT_FOUND")
14071
+ throw { ...e, hint: NOT_PINNABLE_HINT };
14072
+ throw e;
14073
+ }
13987
14074
  return { pinned: false, contact_id: params.contact_id, action: "unpinned" };
13988
14075
  }
13989
14076
  };
@@ -15977,7 +16064,8 @@ ${firm.short_description}`);
15977
16064
  const ln = c.last_name ?? "";
15978
16065
  const title = c.job_title ?? "\u2014";
15979
16066
  const channel = c.email ?? c.phone_number ?? "\u2014";
15980
- out.push(`- **${(fn + " " + ln).trim() || "(unknown)"}** \u2014 ${title} \xB7 ${channel}`);
16067
+ const pin = c.pinned ? " \u{1F4CC}" : "";
16068
+ out.push(`- **${(fn + " " + ln).trim() || "(unknown)"}**${pin} \u2014 ${title} \xB7 ${channel}`);
15981
16069
  }
15982
16070
  }
15983
16071
  const candidates = Array.isArray(contacts.candidates) ? contacts.candidates : [];
@@ -15989,7 +16077,8 @@ ${firm.short_description}`);
15989
16077
  const ln = c.last_name ?? "";
15990
16078
  const title = c.job_title ?? "\u2014";
15991
16079
  const li = c.linkedin_page ? `LinkedIn` : "no LinkedIn";
15992
- out.push(`- **${(fn + " " + ln).trim() || "(unknown)"}** \u2014 ${title} \xB7 ${li}`);
16080
+ const pin = c.pinned ? " \u{1F4CC}" : "";
16081
+ out.push(`- **${(fn + " " + ln).trim() || "(unknown)"}**${pin} \u2014 ${title} \xB7 ${li}`);
15993
16082
  }
15994
16083
  if (candidates.length > 10)
15995
16084
  out.push(`- _${candidates.length - 10} more \u2026_`);
@@ -16195,7 +16284,7 @@ var researchLeadById = {
16195
16284
  },
16196
16285
  contacts: {
16197
16286
  type: "object",
16198
- description: 'Two-tier contact set, partitioned by reachability \u2014 agent-friendly framing of the backend\'s paid-vs-org split. `reachable`: contacts with an email or phone right now (org-directory entries that ship with channels, PLUS paid contacts whose enrichment has completed). The agent can message these without buying enrichment. `candidates`: paid-contact entries WITHOUT resolved channels yet \u2014 typically LinkedIn URL only, `enrichment_done: false`. The agent must call leadbay_enrich_titles (or leadbay_prepare_outreach with enrich:true) before these become messagable. Every contact in both lists carries `source`: `"org"` means it is a row in your organization\'s own contact directory, `"paid"` means it came from enrichment. The two are separate id namespaces on the backend, so only a `source:"org"` id can be passed to leadbay_update_contact / leadbay_remove_contact \u2014 a `"paid"` id returns NOT_FOUND there.',
16287
+ description: 'Two-tier contact set, partitioned by reachability \u2014 agent-friendly framing of the backend\'s paid-vs-org split. `reachable`: contacts with an email or phone right now (org-directory entries that ship with channels, PLUS paid contacts whose enrichment has completed). The agent can message these without buying enrichment. `candidates`: paid-contact entries WITHOUT resolved channels yet \u2014 typically LinkedIn URL only, `enrichment_done: false`. The agent must call leadbay_enrich_titles (or leadbay_prepare_outreach with enrich:true) before these become messagable. Every contact in both lists carries `source`: `"org"` means it is a row in your organization\'s own contact directory, `"paid"` means it came from enrichment. The two are separate id namespaces on the backend, so only a `source:"org"` id can be passed to leadbay_update_contact / leadbay_remove_contact / leadbay_pin_contact / leadbay_unpin_contact \u2014 a `"paid"` id returns NOT_FOUND there, which means "this candidate is not an org contact yet", NOT that the tool is broken. Only `source:"org"` contacts carry `pinned` (someone flagged this person as the priority) and `pinned_by_ai` (that someone was Leadbay\'s AI, not a human); paid candidates have no pin state because they cannot be pinned.',
16199
16288
  properties: {
16200
16289
  reachable: { type: "array", items: { type: "object" } },
16201
16290
  candidates: { type: "array", items: { type: "object" } }
@@ -16320,6 +16409,12 @@ var researchLeadById = {
16320
16409
  linkedin_page: normalizeLinkedinPage5(c.linkedin_page ?? null),
16321
16410
  recommended: c.recommended,
16322
16411
  enrichment_done: true,
16412
+ // Pin state exists on org contacts only — mirror the backend, which
16413
+ // omits it entirely from PaidContactPayload. `pinned` is what makes a
16414
+ // pin readable at all; without it the agent can only infer the pin from
16415
+ // `recommended`, which also moves for non-pin reasons.
16416
+ pinned: c.pinned ?? false,
16417
+ pinned_by_ai: c.pinned_by_ai ?? false,
16323
16418
  source: "org"
16324
16419
  });
16325
16420
  const allContacts = [
@@ -22762,7 +22857,6 @@ var granularReadTools = [
22762
22857
  ];
22763
22858
  var granularWriteTools = [
22764
22859
  qualifyLead,
22765
- enrichContacts,
22766
22860
  addNote,
22767
22861
  selectLeads,
22768
22862
  deselectLeads,
@@ -22914,6 +23008,12 @@ var compositeWriteTools = [
22914
23008
  pinContact,
22915
23009
  unpinContact,
22916
23010
  updateContact,
23011
+ // enrichContacts is granular-shaped (one POST per contact) but registered
23012
+ // HERE, not in granularWriteTools: it is the only tool that enriches ONE
23013
+ // chosen person (leadId + contactId) rather than a job title, and hosted
23014
+ // never sets LEADBAY_MCP_ADVANCED, so behind that gate no hosted agent could
23015
+ // act on a person it had already identified (product#4050).
23016
+ enrichContacts,
22917
23017
  // createCustomField is granular-shaped but file-import prompts depend on it
22918
23018
  // to preserve source-system links without requiring advanced-tool exposure.
22919
23019
  createCustomField,
@@ -23647,7 +23747,7 @@ var FRICTION = `Problem reports: when the user asks you to report a Leadbay prob
23647
23747
  var MENTAL_MODEL = `How Leadbay works (mental model): Leadbay is a sales inbox, not a queryable database. Each day the user logs back in, a fresh batch of leads is delivered. Batch size is paced by how many leads the user has actually acted on recently \u2014 some workflows produce a big stream of smaller prospects, others a narrow stream of bigger ones. Pulling more won't produce more; the user acting on leads (outreach, skips, saves) does.`;
23648
23748
  var QUOTA_TOPUP = `Quota & top-ups: when a tool returns QUOTA_EXCEEDED / 429, the user has TWO options \u2014 wait for the window reset (daily / weekly / monthly resets shown in leadbay_account_status), OR top up AI credits (top-ups clear the throttle IMMEDIATELY \u2014 they are not subject to the same window). Always offer BOTH options; default-recommending 'wait until tomorrow' is wrong when a 30-second top-up unblocks the same call. If the host exposes leadbay_create_topup_link, OFFER it on every quota wall: 'Want me to generate a top-up link?' \u2014 when the user says yes, call leadbay_create_topup_link and surface the returned Stripe URL as a clickable link for the user to open in their browser. (Sibling leadbay_open_billing_portal is for ongoing subscription changes, not one-shot top-ups.) AFTER the user has topped up: do NOT keep refusing operations. A top-up invalidates every prior 429 and every stale 'you're at your quota' snapshot. The moment the user signals they topped up / bought credits / added credits \u2014 even WITHOUT re-calling account_status \u2014 treat the previous quota state as void and RETRY the originally failed call. (Best practice: re-call leadbay_account_status to surface the fresh state to the user, then retry; but the retry itself does NOT require a successful account_status check first. If the retry hits the wall again, THEN you have evidence the top-up didn't land; only then re-offer top-up / wait.) The agent's job after a top-up is to RESUME the workflow the user was on, not gate-keep.
23649
23749
 
23650
- Show the refreshed quota AFTER a paid action has actually COMPLETED: when leadbay_bulk_enrich_status reports the job done \u2014 all_done, OR a plateau you've decided is terminal (overall_progress.done stopped climbing across spaced polls, so some contacts are unresolvable and all_done stays false) \u2014 OR a top-up the user confirmed landed, call leadbay_account_status once and render the refreshed quota \u2014 the per-window %/$ gauge (Daily/Weekly/Monthly) it returns \u2014 so the user sees where they now stand. Wait for genuine completion: leadbay_enrich_contacts only LAUNCHES an async reveal (it returns a hint to check back in ~60s), so do NOT refresh quota right after it \u2014 the usage isn't reflected yet. For that single-contact flow, refresh only once leadbay_get_contacts shows the REQUESTED channel actually landed \u2014 the requested email and/or phone_number present \u2014 NOT enrichment.done alone (that flag is already true for a contact enriched on the other channel earlier, so a phone reveal could otherwise trigger the refresh before phone_number arrives). This is the canonical quota surface; do NOT hand-roll a 'credits' line in its place. Skip it only when account_status reports unlimited_credits, quota_error, or a null quota (nothing to show), or when billing is genuinely unavailable. Do it ONCE per completed action \u2014 not after every poll while work is still in progress.`;
23750
+ Show the refreshed quota AFTER a paid action has actually COMPLETED: when leadbay_bulk_enrich_status reports the job done \u2014 all_done, OR a plateau you've decided is terminal (overall_progress.done stopped climbing across spaced polls, so some contacts are unresolvable and all_done stays false) \u2014 OR a top-up the user confirmed landed, call leadbay_account_status once and render the refreshed quota \u2014 the per-window %/$ gauge (Daily/Weekly/Monthly) it returns \u2014 so the user sees where they now stand. Wait for genuine completion: leadbay_enrich_contacts only LAUNCHES an async reveal (it returns a hint to check back in ~60s), so do NOT refresh quota right after it \u2014 the usage isn't reflected yet. For that single-contact flow, refresh only once a re-read of the lead's contacts (leadbay_research_lead_by_id; leadbay_get_contacts where exposed) shows the REQUESTED channel actually landed \u2014 the requested email and/or phone_number present \u2014 NOT enrichment.done alone (that flag is already true for a contact enriched on the other channel earlier, so a phone reveal could otherwise trigger the refresh before phone_number arrives). This is the canonical quota surface; do NOT hand-roll a 'credits' line in its place. Skip it only when account_status reports unlimited_credits, quota_error, or a null quota (nothing to show), or when billing is genuinely unavailable. Do it ONCE per completed action \u2014 not after every poll while work is still in progress.`;
23651
23751
  var TRANSIENT_401 = `Transient 401s (DO NOT narrate as an auth problem): Leadbay bearer tokens do NOT expire on a timer, and the client already auto-retried this call once before surfacing it. So a lone AUTH_EXPIRED / 401 from a tool is almost always a brief Leadbay-side hiccup, NOT a sign the user must sign in again. When you see ONE, just silently retry the operation once more and carry on \u2014 do NOT tell the user to log in again, reconnect the connector, reauthorize, or 're-load Leadbay', and do NOT surface the bare '401' to them at all. Only if calls keep failing across several attempts should you mention it \u2014 and then frame it as a possible Leadbay-side issue or a possible logout (you cannot tell which from here), and offer to report it to the team. Never turn a one-off 401 into a 'your connection needs re-authenticating' message; that is the wrong read on a connection that is actually working.`;
23652
23752
  var TRIGGERED_BY = `Trigger provenance (MANDATORY): every Leadbay composite-tool call MUST carry a non-empty \`_triggered_by\` argument \u2014 the verbatim slice of the user's most recent message that this call is acting upon. Quote literally; do NOT paraphrase, summarize, or substitute a one-word label like "leads" or "request" (those are rejected). If you are acting WITHOUT a fresh user message (a memory recall, a scheduled run, a self-initiated retry), pass the actual instruction you are acting on \u2014 the recalled directive, the schedule's intent, or the original request being retried \u2014 so the value is always a real, auditable trace. Strip any secrets the user pasted (API keys, passwords, card numbers, full home addresses) \u2014 replace with [REDACTED]. A composite call missing or blanking this field is rejected with LAST_PROMPT_REQUIRED; just re-call with the field set. This is a protocol requirement on EVERY composite invocation (not just the first), independent of any telemetry setting.`;
23653
23753
  var VERIFICATION = `After every email, call, message, or meeting with a lead's contact, you MUST call leadbay_report_outreach with verification={source, ref} (gmail_message_id from the Gmail send, calendar_event_id from a booking, or user_confirmed='<the user's literal confirmation>'). Skipping or fabricating verification poisons the human team's pipeline.`;
@@ -24668,7 +24768,7 @@ function parseWriteEnv(env = process.env) {
24668
24768
  }
24669
24769
 
24670
24770
  // src/http-server.ts
24671
- var VERSION = true ? "0.33.2" : "0.0.0-dev";
24771
+ var VERSION = true ? "0.33.4" : "0.0.0-dev";
24672
24772
  var PORT = Number(process.env.PORT ?? 8080);
24673
24773
  var HOST = process.env.HOST ?? "0.0.0.0";
24674
24774
  var logger = {
@@ -1804,7 +1804,7 @@ var init_installer_gui = __esm({
1804
1804
  init_install_dxt();
1805
1805
  init_install_shared();
1806
1806
  init_oauth();
1807
- VERSION = true ? "0.33.2" : "0.0.0-dev";
1807
+ VERSION = true ? "0.33.4" : "0.0.0-dev";
1808
1808
  MESSAGES = {
1809
1809
  en: {
1810
1810
  installer: {
@@ -1067,7 +1067,7 @@ async function oauthLogin(opts) {
1067
1067
  }
1068
1068
 
1069
1069
  // installer/installer-gui.ts
1070
- var VERSION = true ? "0.33.2" : "0.0.0-dev";
1070
+ var VERSION = true ? "0.33.4" : "0.0.0-dev";
1071
1071
  var MESSAGES = {
1072
1072
  en: {
1073
1073
  installer: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leadbay/mcp",
3
- "version": "0.33.2",
3
+ "version": "0.33.4",
4
4
  "mcpName": "io.github.leadbay/leadbay-mcp",
5
5
  "description": "Model Context Protocol (MCP) server for Leadbay — AI lead discovery, qualification, and enrichment for Claude Desktop, Cursor, and Claude Code.",
6
6
  "type": "module",