@leadbay/mcp 0.33.2 → 0.33.3

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,59 @@
1
1
  # Changelog — @leadbay/mcp
2
2
 
3
+ ## 0.33.3 — 2026-09-02
4
+
5
+ `leadbay_pin_contact` failed 43 of its 48 production calls.
6
+
7
+ Measured over the 180 days to 2026-09-02 (PostHog `mcp tool called`,
8
+ `properties.ok = false`). Sentry issue `MCP-3A` carries exactly 43 events, so
9
+ every `contact not found` in the `mcp` project is a pin call. 41 of the 43 are
10
+ one scheduled agent, `zoe+dogfood@leadbay.ai`, across 12 days and MCP 0.26.0
11
+ through 0.33.2. Its own `triggered_by` reads *"tâche planifiée : épingler le DG
12
+ Mickael Hamot (CROMOLOGY SERVICES) à la place du président"*. It picks the DG
13
+ out of `leadbay_research_lead_by_id` and pins that id, which on an unenriched
14
+ company is a `source: "paid"` candidate. It has never once succeeded, and pin
15
+ is its third most used tool.
16
+
17
+ `POST /contacts/{id}/pin` resolves through `org_contacts` only
18
+ (`OrgContactRoutes.kt:44`), so a paid candidate id can never resolve there. Two
19
+ changes:
20
+
21
+ - **The 404 now names its cause.** The client's shared 404 hint is "Verify the
22
+ ID is correct" (`client.ts:1048`), which on this endpoint is false: the id is
23
+ correct, it is the wrong namespace. An agent reads that as "look it up and
24
+ retry" and does, in bursts of up to 10 calls in 13 seconds. pin and unpin now
25
+ catch `NOT_FOUND` and replace the hint with one that names the org-vs-paid
26
+ split, says not to retry, and names the tools that make the person pinnable.
27
+ - **The descriptions carry the rule.** New shared snippet
28
+ `snippets/heuristics/pinnable-contacts.md`, included by both templates, plus
29
+ the rule in `prefer_when` so it lands in the first 600 chars every host reads.
30
+ It also states that pinning does not steer enrichment: enrichment selects by
31
+ job title (`resolveAutoIncludedTitles` →
32
+ `paidContacts.findSimilarJobTitlesWithScores`) and `pinnedBy` plays no part.
33
+ That was the second half of the agent's mistake.
34
+
35
+ **`pinned` is now readable.** The backend's `ContactPayload` has carried
36
+ `pinned` + `pinned_by_ai` all along; every MCP shaping site dropped them, so a
37
+ pin could be written but not read back except by watching `recommended`, which
38
+ moves for other reasons too. Now passed through in `research_lead_by_id`,
39
+ `get_contacts` and `get_lead_profile`, and marked `📌` in both markdown contact
40
+ lists. Deliberately asymmetric: `PaidContactPayload` has no pin state, so paid
41
+ contacts get no `pinned` key rather than a synthetic `false`.
42
+
43
+ Verified live against FR staging, running the built branch:
44
+
45
+ ```
46
+ BEFORE PIN: org contact → pinned: false paid contacts → no pinned key
47
+ AFTER PIN: org contact → pinned: true (pin http 204)
48
+ AFTER UNPIN: org contact → pinned: false (unpin http 204)
49
+ ```
50
+
51
+ Remaining, not fixed here: on hosted an agent still has no way to enrich one
52
+ named person. `leadbay_enrich_contacts` takes a `contact_id` and does exactly
53
+ that, but sits in `granularWriteTools` behind `LEADBAY_MCP_ADVANCED=1`, and no
54
+ granular tool has been called from a hosted IP in 30 days. That is why the
55
+ agent reached for pin. Filed as leadbay/product#4050.
56
+
3
57
  ## 0.33.2 — 2026-09-02
4
58
 
5
59
  Editing one field on a contact erased the others (product#4046).
package/dist/bin.js CHANGED
@@ -3688,7 +3688,7 @@ Trigger phrases: "pin this contact", "mark this person as priority", "make this
3688
3688
 
3689
3689
  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
3690
 
3691
- Prefer when: user wants ONE person flagged as the priority on a company \u2014 pass that contact's own \`contact_id\`
3691
+ 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
3692
 
3693
3693
  Examples that SHOULD invoke this tool:
3694
3694
  - "Pin Jane Doe as the main contact on this company."
@@ -3710,9 +3710,24 @@ Pin a single contact on a company so it surfaces first as a priority / favourite
3710
3710
 
3711
3711
  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
3712
 
3713
+ **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:
3714
+
3715
+ - \`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).
3716
+ - \`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.
3717
+
3718
+ 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.
3719
+
3720
+ To pin someone who is currently only a candidate, first make them an org contact:
3721
+
3722
+ - \`leadbay_enrich_titles\` (or \`leadbay_prepare_outreach\` with \`enrich: true\`) resolves the candidate and writes a NEW org contact for that person. It has a **different \`id\`** from the paid candidate, so re-read the contacts list afterwards and pin the \`source: "org"\` row.
3723
+ - Or add them directly with \`leadbay_add_contact\`, which returns the new org contact's \`id\` \u2014 that id is pinnable immediately.
3724
+
3725
+ **Pinning does not steer enrichment.** It only marks who the priority is on a company the user already has. Enrichment picks people by JOB TITLE, so "enrich the Directeur G\xE9n\xE9ral rather than the Pr\xE9sident" is \`leadbay_enrich_titles\` with the wanted title \u2014 not a pin. Pinning first and enriching after changes nothing about who gets enriched.
3726
+
3727
+
3713
3728
  Backend: \`POST /contacts/{contact_id}/pin\` \u2192 204. Idempotent. The inverse is \`leadbay_unpin_contact\`.
3714
3729
 
3715
- Returns \`{ pinned: true, contact_id, action: "pinned" }\`.
3730
+ 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
3731
 
3717
3732
  Requires: LEADBAY_MCP_WRITE=1 (MCP) or exposeWrite=true (OpenClaw).
3718
3733
  `;
@@ -5657,7 +5672,7 @@ Trigger phrases: "unpin this contact", "remove the pin from this contact", "this
5657
5672
 
5658
5673
  Do NOT use for: "pin / mark as priority" \u2192 \`leadbay_pin_contact\`; "remove / delete this contact" \u2192 \`leadbay_remove_contact\`.
5659
5674
 
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\`
5675
+ 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
5676
 
5662
5677
  Examples that SHOULD invoke this tool:
5663
5678
  - "Unpin Jane Doe \u2014 she's not the priority anymore."
@@ -5680,9 +5695,26 @@ Unpin a single contact on a company \u2014 clears its priority / favourite flag.
5680
5695
 
5681
5696
  Pass the contact's **own** \`contact_id\` \u2014 not the parent lead id.
5682
5697
 
5698
+ **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:
5699
+
5700
+ - \`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).
5701
+ - \`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.
5702
+
5703
+ 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.
5704
+
5705
+ To pin someone who is currently only a candidate, first make them an org contact:
5706
+
5707
+ - \`leadbay_enrich_titles\` (or \`leadbay_prepare_outreach\` with \`enrich: true\`) resolves the candidate and writes a NEW org contact for that person. It has a **different \`id\`** from the paid candidate, so re-read the contacts list afterwards and pin the \`source: "org"\` row.
5708
+ - Or add them directly with \`leadbay_add_contact\`, which returns the new org contact's \`id\` \u2014 that id is pinnable immediately.
5709
+
5710
+ **Pinning does not steer enrichment.** It only marks who the priority is on a company the user already has. Enrichment picks people by JOB TITLE, so "enrich the Directeur G\xE9n\xE9ral rather than the Pr\xE9sident" is \`leadbay_enrich_titles\` with the wanted title \u2014 not a pin. Pinning first and enriching after changes nothing about who gets enriched.
5711
+
5712
+
5713
+ 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.
5714
+
5683
5715
  Backend: \`POST /contacts/{contact_id}/unpin\` \u2192 204. Idempotent. The inverse is \`leadbay_pin_contact\`.
5684
5716
 
5685
- Returns \`{ pinned: false, contact_id, action: "unpinned" }\`.
5717
+ 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
5718
 
5687
5719
  Requires: LEADBAY_MCP_WRITE=1 (MCP) or exposeWrite=true (OpenClaw).
5688
5720
  `;
@@ -6086,6 +6118,10 @@ var init_get_lead_profile = __esm({
6086
6118
  job_title: c.job_title,
6087
6119
  recommended: c.recommended,
6088
6120
  enrichment: c.enrichment,
6121
+ // Org contacts only — the backend's PaidContactPayload carries no pin
6122
+ // state, because a paid candidate cannot be pinned.
6123
+ pinned: c.pinned ?? false,
6124
+ pinned_by_ai: c.pinned_by_ai ?? false,
6089
6125
  source: "org"
6090
6126
  })),
6091
6127
  ...paidContacts.map((c) => ({
@@ -6177,7 +6213,7 @@ var init_get_contacts = __esm({
6177
6213
  properties: {
6178
6214
  contacts: {
6179
6215
  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'}.",
6216
+ 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
6217
  items: {
6182
6218
  type: "object",
6183
6219
  properties: {
@@ -6189,6 +6225,14 @@ var init_get_contacts = __esm({
6189
6225
  linkedin_page: { type: ["string", "null"] },
6190
6226
  job_title: { type: ["string", "null"] },
6191
6227
  recommended: { type: "boolean" },
6228
+ pinned: {
6229
+ type: "boolean",
6230
+ description: "Someone flagged this person as the priority on the company. Present on source:'org' contacts only."
6231
+ },
6232
+ pinned_by_ai: {
6233
+ type: "boolean",
6234
+ description: "The pin came from Leadbay's AI rather than a human. Present on source:'org' contacts only."
6235
+ },
6192
6236
  source: { type: "string", enum: ["org", "paid"] },
6193
6237
  enrichment: {
6194
6238
  type: ["object", "null"],
@@ -6247,6 +6291,10 @@ var init_get_contacts = __esm({
6247
6291
  job_title: c.job_title,
6248
6292
  recommended: c.recommended,
6249
6293
  enrichment: c.enrichment,
6294
+ // Org contacts only — the backend's PaidContactPayload carries no
6295
+ // pin state, because a paid candidate cannot be pinned.
6296
+ pinned: c.pinned ?? false,
6297
+ pinned_by_ai: c.pinned_by_ai ?? false,
6250
6298
  source: "org"
6251
6299
  })),
6252
6300
  ...paidContacts.map((c) => ({
@@ -11785,11 +11833,12 @@ var init_remove_contact = __esm({
11785
11833
  });
11786
11834
 
11787
11835
  // ../core/dist/tools/pin-contact.js
11788
- var pinContact;
11836
+ var NOT_PINNABLE_HINT, pinContact;
11789
11837
  var init_pin_contact = __esm({
11790
11838
  "../core/dist/tools/pin-contact.js"() {
11791
11839
  "use strict";
11792
11840
  init_tool_descriptions_generated();
11841
+ 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 by job title with leadbay_enrich_titles, or add them with leadbay_add_contact \u2014 either produces a NEW org contact with a different id, which is pinnable. Note that pinning does not decide who gets enriched; enrichment selects people by job title.';
11793
11842
  pinContact = {
11794
11843
  name: "leadbay_pin_contact",
11795
11844
  description: leadbay_pin_contact,
@@ -11813,7 +11862,13 @@ var init_pin_contact = __esm({
11813
11862
  additionalProperties: false
11814
11863
  },
11815
11864
  execute: async (client, params, _ctx) => {
11816
- await client.requestVoid("POST", `/contacts/${params.contact_id}/pin`);
11865
+ try {
11866
+ await client.requestVoid("POST", `/contacts/${params.contact_id}/pin`);
11867
+ } catch (e) {
11868
+ if (e?.code === "NOT_FOUND")
11869
+ throw { ...e, hint: NOT_PINNABLE_HINT };
11870
+ throw e;
11871
+ }
11817
11872
  return { pinned: true, contact_id: params.contact_id, action: "pinned" };
11818
11873
  }
11819
11874
  };
@@ -11826,6 +11881,7 @@ var init_unpin_contact = __esm({
11826
11881
  "../core/dist/tools/unpin-contact.js"() {
11827
11882
  "use strict";
11828
11883
  init_tool_descriptions_generated();
11884
+ init_pin_contact();
11829
11885
  unpinContact = {
11830
11886
  name: "leadbay_unpin_contact",
11831
11887
  description: leadbay_unpin_contact,
@@ -11849,7 +11905,13 @@ var init_unpin_contact = __esm({
11849
11905
  additionalProperties: false
11850
11906
  },
11851
11907
  execute: async (client, params, _ctx) => {
11852
- await client.requestVoid("POST", `/contacts/${params.contact_id}/unpin`);
11908
+ try {
11909
+ await client.requestVoid("POST", `/contacts/${params.contact_id}/unpin`);
11910
+ } catch (e) {
11911
+ if (e?.code === "NOT_FOUND")
11912
+ throw { ...e, hint: NOT_PINNABLE_HINT };
11913
+ throw e;
11914
+ }
11853
11915
  return { pinned: false, contact_id: params.contact_id, action: "unpinned" };
11854
11916
  }
11855
11917
  };
@@ -13965,7 +14027,8 @@ ${firm.short_description}`);
13965
14027
  const ln = c.last_name ?? "";
13966
14028
  const title = c.job_title ?? "\u2014";
13967
14029
  const channel = c.email ?? c.phone_number ?? "\u2014";
13968
- out.push(`- **${(fn + " " + ln).trim() || "(unknown)"}** \u2014 ${title} \xB7 ${channel}`);
14030
+ const pin = c.pinned ? " \u{1F4CC}" : "";
14031
+ out.push(`- **${(fn + " " + ln).trim() || "(unknown)"}**${pin} \u2014 ${title} \xB7 ${channel}`);
13969
14032
  }
13970
14033
  }
13971
14034
  const candidates = Array.isArray(contacts.candidates) ? contacts.candidates : [];
@@ -13977,7 +14040,8 @@ ${firm.short_description}`);
13977
14040
  const ln = c.last_name ?? "";
13978
14041
  const title = c.job_title ?? "\u2014";
13979
14042
  const li = c.linkedin_page ? `LinkedIn` : "no LinkedIn";
13980
- out.push(`- **${(fn + " " + ln).trim() || "(unknown)"}** \u2014 ${title} \xB7 ${li}`);
14043
+ const pin = c.pinned ? " \u{1F4CC}" : "";
14044
+ out.push(`- **${(fn + " " + ln).trim() || "(unknown)"}**${pin} \u2014 ${title} \xB7 ${li}`);
13981
14045
  }
13982
14046
  if (candidates.length > 10)
13983
14047
  out.push(`- _${candidates.length - 10} more \u2026_`);
@@ -14189,7 +14253,7 @@ var init_research_lead_by_id = __esm({
14189
14253
  },
14190
14254
  contacts: {
14191
14255
  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.',
14256
+ 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
14257
  properties: {
14194
14258
  reachable: { type: "array", items: { type: "object" } },
14195
14259
  candidates: { type: "array", items: { type: "object" } }
@@ -14314,6 +14378,12 @@ var init_research_lead_by_id = __esm({
14314
14378
  linkedin_page: normalizeLinkedinPage5(c.linkedin_page ?? null),
14315
14379
  recommended: c.recommended,
14316
14380
  enrichment_done: true,
14381
+ // Pin state exists on org contacts only — mirror the backend, which
14382
+ // omits it entirely from PaidContactPayload. `pinned` is what makes a
14383
+ // pin readable at all; without it the agent can only infer the pin from
14384
+ // `recommended`, which also moves for non-pin reasons.
14385
+ pinned: c.pinned ?? false,
14386
+ pinned_by_ai: c.pinned_by_ai ?? false,
14317
14387
  source: "org"
14318
14388
  });
14319
14389
  const allContacts = [
@@ -27901,7 +27971,7 @@ var OAUTH_BASE_URLS = {
27901
27971
  fr: "https://staging.api.leadbay.app"
27902
27972
  }
27903
27973
  };
27904
- var VERSION = "0.33.2";
27974
+ var VERSION = "0.33.3";
27905
27975
  var HELP = `
27906
27976
  leadbay-mcp ${VERSION} \u2014 Leadbay Model Context Protocol server
27907
27977
 
@@ -6279,7 +6279,7 @@ Trigger phrases: "pin this contact", "mark this person as priority", "make this
6279
6279
 
6280
6280
  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
6281
 
6282
- Prefer when: user wants ONE person flagged as the priority on a company \u2014 pass that contact's own \`contact_id\`
6282
+ 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
6283
 
6284
6284
  Examples that SHOULD invoke this tool:
6285
6285
  - "Pin Jane Doe as the main contact on this company."
@@ -6301,9 +6301,24 @@ Pin a single contact on a company so it surfaces first as a priority / favourite
6301
6301
 
6302
6302
  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
6303
 
6304
+ **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:
6305
+
6306
+ - \`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).
6307
+ - \`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.
6308
+
6309
+ 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.
6310
+
6311
+ To pin someone who is currently only a candidate, first make them an org contact:
6312
+
6313
+ - \`leadbay_enrich_titles\` (or \`leadbay_prepare_outreach\` with \`enrich: true\`) resolves the candidate and writes a NEW org contact for that person. It has a **different \`id\`** from the paid candidate, so re-read the contacts list afterwards and pin the \`source: "org"\` row.
6314
+ - Or add them directly with \`leadbay_add_contact\`, which returns the new org contact's \`id\` \u2014 that id is pinnable immediately.
6315
+
6316
+ **Pinning does not steer enrichment.** It only marks who the priority is on a company the user already has. Enrichment picks people by JOB TITLE, so "enrich the Directeur G\xE9n\xE9ral rather than the Pr\xE9sident" is \`leadbay_enrich_titles\` with the wanted title \u2014 not a pin. Pinning first and enriching after changes nothing about who gets enriched.
6317
+
6318
+
6304
6319
  Backend: \`POST /contacts/{contact_id}/pin\` \u2192 204. Idempotent. The inverse is \`leadbay_unpin_contact\`.
6305
6320
 
6306
- Returns \`{ pinned: true, contact_id, action: "pinned" }\`.
6321
+ 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
6322
 
6308
6323
  Requires: LEADBAY_MCP_WRITE=1 (MCP) or exposeWrite=true (OpenClaw).
6309
6324
  `;
@@ -8248,7 +8263,7 @@ Trigger phrases: "unpin this contact", "remove the pin from this contact", "this
8248
8263
 
8249
8264
  Do NOT use for: "pin / mark as priority" \u2192 \`leadbay_pin_contact\`; "remove / delete this contact" \u2192 \`leadbay_remove_contact\`.
8250
8265
 
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\`
8266
+ 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
8267
 
8253
8268
  Examples that SHOULD invoke this tool:
8254
8269
  - "Unpin Jane Doe \u2014 she's not the priority anymore."
@@ -8271,9 +8286,26 @@ Unpin a single contact on a company \u2014 clears its priority / favourite flag.
8271
8286
 
8272
8287
  Pass the contact's **own** \`contact_id\` \u2014 not the parent lead id.
8273
8288
 
8289
+ **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:
8290
+
8291
+ - \`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).
8292
+ - \`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.
8293
+
8294
+ 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.
8295
+
8296
+ To pin someone who is currently only a candidate, first make them an org contact:
8297
+
8298
+ - \`leadbay_enrich_titles\` (or \`leadbay_prepare_outreach\` with \`enrich: true\`) resolves the candidate and writes a NEW org contact for that person. It has a **different \`id\`** from the paid candidate, so re-read the contacts list afterwards and pin the \`source: "org"\` row.
8299
+ - Or add them directly with \`leadbay_add_contact\`, which returns the new org contact's \`id\` \u2014 that id is pinnable immediately.
8300
+
8301
+ **Pinning does not steer enrichment.** It only marks who the priority is on a company the user already has. Enrichment picks people by JOB TITLE, so "enrich the Directeur G\xE9n\xE9ral rather than the Pr\xE9sident" is \`leadbay_enrich_titles\` with the wanted title \u2014 not a pin. Pinning first and enriching after changes nothing about who gets enriched.
8302
+
8303
+
8304
+ 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.
8305
+
8274
8306
  Backend: \`POST /contacts/{contact_id}/unpin\` \u2192 204. Idempotent. The inverse is \`leadbay_pin_contact\`.
8275
8307
 
8276
- Returns \`{ pinned: false, contact_id, action: "unpinned" }\`.
8308
+ 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
8309
 
8278
8310
  Requires: LEADBAY_MCP_WRITE=1 (MCP) or exposeWrite=true (OpenClaw).
8279
8311
  `;
@@ -8648,6 +8680,10 @@ var getLeadProfile = {
8648
8680
  job_title: c.job_title,
8649
8681
  recommended: c.recommended,
8650
8682
  enrichment: c.enrichment,
8683
+ // Org contacts only — the backend's PaidContactPayload carries no pin
8684
+ // state, because a paid candidate cannot be pinned.
8685
+ pinned: c.pinned ?? false,
8686
+ pinned_by_ai: c.pinned_by_ai ?? false,
8651
8687
  source: "org"
8652
8688
  })),
8653
8689
  ...paidContacts.map((c) => ({
@@ -8732,7 +8768,7 @@ var getContacts = {
8732
8768
  properties: {
8733
8769
  contacts: {
8734
8770
  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'}.",
8771
+ 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
8772
  items: {
8737
8773
  type: "object",
8738
8774
  properties: {
@@ -8744,6 +8780,14 @@ var getContacts = {
8744
8780
  linkedin_page: { type: ["string", "null"] },
8745
8781
  job_title: { type: ["string", "null"] },
8746
8782
  recommended: { type: "boolean" },
8783
+ pinned: {
8784
+ type: "boolean",
8785
+ description: "Someone flagged this person as the priority on the company. Present on source:'org' contacts only."
8786
+ },
8787
+ pinned_by_ai: {
8788
+ type: "boolean",
8789
+ description: "The pin came from Leadbay's AI rather than a human. Present on source:'org' contacts only."
8790
+ },
8747
8791
  source: { type: "string", enum: ["org", "paid"] },
8748
8792
  enrichment: {
8749
8793
  type: ["object", "null"],
@@ -8802,6 +8846,10 @@ var getContacts = {
8802
8846
  job_title: c.job_title,
8803
8847
  recommended: c.recommended,
8804
8848
  enrichment: c.enrichment,
8849
+ // Org contacts only — the backend's PaidContactPayload carries no
8850
+ // pin state, because a paid candidate cannot be pinned.
8851
+ pinned: c.pinned ?? false,
8852
+ pinned_by_ai: c.pinned_by_ai ?? false,
8805
8853
  source: "org"
8806
8854
  })),
8807
8855
  ...paidContacts.map((c) => ({
@@ -13931,6 +13979,7 @@ var removeContact = {
13931
13979
  };
13932
13980
 
13933
13981
  // ../core/dist/tools/pin-contact.js
13982
+ 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 by job title with leadbay_enrich_titles, or add them with leadbay_add_contact \u2014 either produces a NEW org contact with a different id, which is pinnable. Note that pinning does not decide who gets enriched; enrichment selects people by job title.';
13934
13983
  var pinContact = {
13935
13984
  name: "leadbay_pin_contact",
13936
13985
  description: leadbay_pin_contact,
@@ -13954,7 +14003,13 @@ var pinContact = {
13954
14003
  additionalProperties: false
13955
14004
  },
13956
14005
  execute: async (client, params, _ctx) => {
13957
- await client.requestVoid("POST", `/contacts/${params.contact_id}/pin`);
14006
+ try {
14007
+ await client.requestVoid("POST", `/contacts/${params.contact_id}/pin`);
14008
+ } catch (e) {
14009
+ if (e?.code === "NOT_FOUND")
14010
+ throw { ...e, hint: NOT_PINNABLE_HINT };
14011
+ throw e;
14012
+ }
13958
14013
  return { pinned: true, contact_id: params.contact_id, action: "pinned" };
13959
14014
  }
13960
14015
  };
@@ -13983,7 +14038,13 @@ var unpinContact = {
13983
14038
  additionalProperties: false
13984
14039
  },
13985
14040
  execute: async (client, params, _ctx) => {
13986
- await client.requestVoid("POST", `/contacts/${params.contact_id}/unpin`);
14041
+ try {
14042
+ await client.requestVoid("POST", `/contacts/${params.contact_id}/unpin`);
14043
+ } catch (e) {
14044
+ if (e?.code === "NOT_FOUND")
14045
+ throw { ...e, hint: NOT_PINNABLE_HINT };
14046
+ throw e;
14047
+ }
13987
14048
  return { pinned: false, contact_id: params.contact_id, action: "unpinned" };
13988
14049
  }
13989
14050
  };
@@ -15977,7 +16038,8 @@ ${firm.short_description}`);
15977
16038
  const ln = c.last_name ?? "";
15978
16039
  const title = c.job_title ?? "\u2014";
15979
16040
  const channel = c.email ?? c.phone_number ?? "\u2014";
15980
- out.push(`- **${(fn + " " + ln).trim() || "(unknown)"}** \u2014 ${title} \xB7 ${channel}`);
16041
+ const pin = c.pinned ? " \u{1F4CC}" : "";
16042
+ out.push(`- **${(fn + " " + ln).trim() || "(unknown)"}**${pin} \u2014 ${title} \xB7 ${channel}`);
15981
16043
  }
15982
16044
  }
15983
16045
  const candidates = Array.isArray(contacts.candidates) ? contacts.candidates : [];
@@ -15989,7 +16051,8 @@ ${firm.short_description}`);
15989
16051
  const ln = c.last_name ?? "";
15990
16052
  const title = c.job_title ?? "\u2014";
15991
16053
  const li = c.linkedin_page ? `LinkedIn` : "no LinkedIn";
15992
- out.push(`- **${(fn + " " + ln).trim() || "(unknown)"}** \u2014 ${title} \xB7 ${li}`);
16054
+ const pin = c.pinned ? " \u{1F4CC}" : "";
16055
+ out.push(`- **${(fn + " " + ln).trim() || "(unknown)"}**${pin} \u2014 ${title} \xB7 ${li}`);
15993
16056
  }
15994
16057
  if (candidates.length > 10)
15995
16058
  out.push(`- _${candidates.length - 10} more \u2026_`);
@@ -16195,7 +16258,7 @@ var researchLeadById = {
16195
16258
  },
16196
16259
  contacts: {
16197
16260
  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.',
16261
+ 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
16262
  properties: {
16200
16263
  reachable: { type: "array", items: { type: "object" } },
16201
16264
  candidates: { type: "array", items: { type: "object" } }
@@ -16320,6 +16383,12 @@ var researchLeadById = {
16320
16383
  linkedin_page: normalizeLinkedinPage5(c.linkedin_page ?? null),
16321
16384
  recommended: c.recommended,
16322
16385
  enrichment_done: true,
16386
+ // Pin state exists on org contacts only — mirror the backend, which
16387
+ // omits it entirely from PaidContactPayload. `pinned` is what makes a
16388
+ // pin readable at all; without it the agent can only infer the pin from
16389
+ // `recommended`, which also moves for non-pin reasons.
16390
+ pinned: c.pinned ?? false,
16391
+ pinned_by_ai: c.pinned_by_ai ?? false,
16323
16392
  source: "org"
16324
16393
  });
16325
16394
  const allContacts = [
@@ -24668,7 +24737,7 @@ function parseWriteEnv(env = process.env) {
24668
24737
  }
24669
24738
 
24670
24739
  // src/http-server.ts
24671
- var VERSION = true ? "0.33.2" : "0.0.0-dev";
24740
+ var VERSION = true ? "0.33.3" : "0.0.0-dev";
24672
24741
  var PORT = Number(process.env.PORT ?? 8080);
24673
24742
  var HOST = process.env.HOST ?? "0.0.0.0";
24674
24743
  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.3" : "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.3" : "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.3",
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",