@leadbay/mcp 0.33.1 → 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 +104 -0
- package/dist/bin.js +138 -29
- package/dist/http-server.js +136 -28
- package/dist/installer-electron.js +1 -1
- package/dist/installer-gui.js +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,109 @@
|
|
|
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
|
+
|
|
57
|
+
## 0.33.2 — 2026-09-02
|
|
58
|
+
|
|
59
|
+
Editing one field on a contact erased the others (product#4046).
|
|
60
|
+
|
|
61
|
+
Reproduced on production while verifying leadbay/mcp#194: sending a contact's
|
|
62
|
+
own current `first_name` + `last_name` + `job_title`, changing nothing, deleted
|
|
63
|
+
that contact's email. The contact moved from `contacts.reachable` to
|
|
64
|
+
`contacts.candidates` and `_meta.has_reachable_contact` flipped to false, so the
|
|
65
|
+
lead stopped being contactable. Restored with a second call.
|
|
66
|
+
|
|
67
|
+
This became reachable only the day before: until #194 the tool returned 404 on
|
|
68
|
+
100% of calls, so it destroyed nothing. A tool that always failed now succeeded
|
|
69
|
+
and deleted data.
|
|
70
|
+
|
|
71
|
+
**The backend already had the safe behaviour and we were choosing the other
|
|
72
|
+
one.** Both routes take the same payload
|
|
73
|
+
(`OrgContactRoutes.kt:110,152` → `OrgContactsDaoImpl.kt:265-272`):
|
|
74
|
+
|
|
75
|
+
| Route | `forceUpdateIfNullOrEmpty` | A field absent from the body |
|
|
76
|
+
|---|---|---|
|
|
77
|
+
| `/contacts/{id}/update` | `true` | written as null — **erased** |
|
|
78
|
+
| `/contacts/{id}/merge` | `false` | skipped — **kept** |
|
|
79
|
+
|
|
80
|
+
So the fix needs no read-modify-write, no extra round-trip and no race window.
|
|
81
|
+
`leadbay_update_contact` now routes by intent:
|
|
82
|
+
|
|
83
|
+
- **Nothing being erased → `/merge`.** Every field the caller omitted survives.
|
|
84
|
+
- **Any field passed as `null` → `/update`**, which rewrites the record — so
|
|
85
|
+
that call must carry all four optional fields. If it does not, the tool
|
|
86
|
+
refuses with `CONTACT_CLEAR_NEEDS_FULL_RECORD` rather than deleting what was
|
|
87
|
+
not mentioned. The destructive path costs more effort than the safe one,
|
|
88
|
+
which is the intended asymmetry.
|
|
89
|
+
- The result carries `mode` (`merge` / `replace`), `preserved` and `cleared`, so
|
|
90
|
+
a wrong edit is visible rather than silent.
|
|
91
|
+
|
|
92
|
+
Verified live on staging, same body to each route:
|
|
93
|
+
|
|
94
|
+
```
|
|
95
|
+
POST /contacts/{id}/merge {first_name, last_name, job_title:"CEO"}
|
|
96
|
+
→ job_title CEO, email merge.probe@example.test, phone +15550001111 (kept)
|
|
97
|
+
POST /contacts/{id}/update {first_name, last_name, job_title:"CTO"}
|
|
98
|
+
→ job_title CTO, email absent, phone absent (erased)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Two existing test files were changed rather than added, because both asserted
|
|
102
|
+
the contract this fixes: `update-contact.test.ts`'s happy path now expects
|
|
103
|
+
`/merge`, and `update-contact-null-clear.test.ts`'s clear case now supplies the
|
|
104
|
+
whole record. The second had encoded the data loss as correct — it asserted that
|
|
105
|
+
unmentioned fields are not sent, while posting to the route that deletes them.
|
|
106
|
+
|
|
3
107
|
## 0.33.1 — 2026-09-02
|
|
4
108
|
|
|
5
109
|
Follow-up to 0.32.0 (product#4007). A review finding landed after the merge.
|
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
|
`;
|
|
@@ -5692,7 +5724,7 @@ Trigger phrases: "update this contact", "fix this contact's title", "change thei
|
|
|
5692
5724
|
|
|
5693
5725
|
Do NOT use for: "add a new contact to this company" \u2192 \`leadbay_add_contact\`; "remove / delete this contact" \u2192 \`leadbay_remove_contact\`; "get email/phone for a contact (enrichment)" \u2192 \`leadbay_enrich_titles\`; "fix an enriched contact's details" \u2192 \`leadbay_add_contact\`.
|
|
5694
5726
|
|
|
5695
|
-
Prefer when: user wants to change details on a contact that is in their own directory (\`source: "org"\`) \u2014 pass
|
|
5727
|
+
Prefer when: user wants to change details on a contact that is in their own directory (\`source: "org"\`) \u2014 pass its \`contact_id\`, first_name, last_name and only the fields being changed
|
|
5696
5728
|
|
|
5697
5729
|
Examples that SHOULD invoke this tool:
|
|
5698
5730
|
- "Update Jane's title to SVP Engineering."
|
|
@@ -5725,11 +5757,30 @@ Pass the contact's **own** \`contact_id\` \u2014 **not** the parent lead id.
|
|
|
5725
5757
|
|
|
5726
5758
|
A 404 from this tool almost always means a \`"paid"\` id was passed. Re-read the contact, check \`source\`, and do not retry the same id.
|
|
5727
5759
|
|
|
5728
|
-
|
|
5760
|
+
## Omitting a field keeps it. Erasing one takes an explicit \`null\`.
|
|
5761
|
+
|
|
5762
|
+
Send only what you are changing. Any field you leave out keeps its current value \u2014 you do NOT need to read the contact first and echo everything back.
|
|
5763
|
+
|
|
5764
|
+
\`\`\`
|
|
5765
|
+
{ contact_id, first_name, last_name, job_title: "CEO" }
|
|
5766
|
+
\u2192 title becomes CEO. email, phone and LinkedIn are untouched.
|
|
5767
|
+
\`\`\`
|
|
5768
|
+
|
|
5769
|
+
**To erase a field, pass it as \`null\`.** Because erasing rewrites the whole record, that call must carry ALL of \`job_title\`, \`linkedin_page\`, \`email\`, \`phone_number\` \u2014 current value for the ones to keep, \`null\` for the ones to erase. If any are missing the call is refused with \`CONTACT_CLEAR_NEEDS_FULL_RECORD\` rather than deleting them; read the contact with \`leadbay_research_lead_by_id\` and re-call.
|
|
5770
|
+
|
|
5771
|
+
\`\`\`
|
|
5772
|
+
{ contact_id, first_name, last_name, email: null,
|
|
5773
|
+
job_title: "CEO", phone_number: "+33\u2026", linkedin_page: "https://\u2026" }
|
|
5774
|
+
\u2192 email erased, everything else as given.
|
|
5775
|
+
\`\`\`
|
|
5776
|
+
|
|
5777
|
+
\`first_name\` + \`last_name\` are required on every call. The backend validates the contact's identity and rejects a body without them (\`invalid contact\`), so pass the current values when you are not changing the name.
|
|
5729
5778
|
|
|
5730
|
-
|
|
5779
|
+
The result tells you which happened: \`mode\` is \`merge\` or \`replace\`, \`preserved\` lists the fields left untouched, \`cleared\` lists the fields erased. **Check \`cleared\` is what you intended.**
|
|
5731
5780
|
|
|
5732
|
-
|
|
5781
|
+
Backend: \`POST /contacts/{contact_id}/merge\` when nothing is being erased, \`POST /contacts/{contact_id}/update\` when something is. Snake_case body; camel-case is rejected. Edits in place (same id).
|
|
5782
|
+
|
|
5783
|
+
Returns \`{ updated: true, contact_id, mode, preserved, cleared, contact: { id, first_name, last_name, job_title, linkedin_page, email, phone_number } }\`.
|
|
5733
5784
|
|
|
5734
5785
|
Requires: LEADBAY_MCP_WRITE=1 (MCP) or exposeWrite=true (OpenClaw).
|
|
5735
5786
|
`;
|
|
@@ -6067,6 +6118,10 @@ var init_get_lead_profile = __esm({
|
|
|
6067
6118
|
job_title: c.job_title,
|
|
6068
6119
|
recommended: c.recommended,
|
|
6069
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,
|
|
6070
6125
|
source: "org"
|
|
6071
6126
|
})),
|
|
6072
6127
|
...paidContacts.map((c) => ({
|
|
@@ -6158,7 +6213,7 @@ var init_get_contacts = __esm({
|
|
|
6158
6213
|
properties: {
|
|
6159
6214
|
contacts: {
|
|
6160
6215
|
type: "array",
|
|
6161
|
-
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.",
|
|
6162
6217
|
items: {
|
|
6163
6218
|
type: "object",
|
|
6164
6219
|
properties: {
|
|
@@ -6170,6 +6225,14 @@ var init_get_contacts = __esm({
|
|
|
6170
6225
|
linkedin_page: { type: ["string", "null"] },
|
|
6171
6226
|
job_title: { type: ["string", "null"] },
|
|
6172
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
|
+
},
|
|
6173
6236
|
source: { type: "string", enum: ["org", "paid"] },
|
|
6174
6237
|
enrichment: {
|
|
6175
6238
|
type: ["object", "null"],
|
|
@@ -6228,6 +6291,10 @@ var init_get_contacts = __esm({
|
|
|
6228
6291
|
job_title: c.job_title,
|
|
6229
6292
|
recommended: c.recommended,
|
|
6230
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,
|
|
6231
6298
|
source: "org"
|
|
6232
6299
|
})),
|
|
6233
6300
|
...paidContacts.map((c) => ({
|
|
@@ -11766,11 +11833,12 @@ var init_remove_contact = __esm({
|
|
|
11766
11833
|
});
|
|
11767
11834
|
|
|
11768
11835
|
// ../core/dist/tools/pin-contact.js
|
|
11769
|
-
var pinContact;
|
|
11836
|
+
var NOT_PINNABLE_HINT, pinContact;
|
|
11770
11837
|
var init_pin_contact = __esm({
|
|
11771
11838
|
"../core/dist/tools/pin-contact.js"() {
|
|
11772
11839
|
"use strict";
|
|
11773
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.';
|
|
11774
11842
|
pinContact = {
|
|
11775
11843
|
name: "leadbay_pin_contact",
|
|
11776
11844
|
description: leadbay_pin_contact,
|
|
@@ -11794,7 +11862,13 @@ var init_pin_contact = __esm({
|
|
|
11794
11862
|
additionalProperties: false
|
|
11795
11863
|
},
|
|
11796
11864
|
execute: async (client, params, _ctx) => {
|
|
11797
|
-
|
|
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
|
+
}
|
|
11798
11872
|
return { pinned: true, contact_id: params.contact_id, action: "pinned" };
|
|
11799
11873
|
}
|
|
11800
11874
|
};
|
|
@@ -11807,6 +11881,7 @@ var init_unpin_contact = __esm({
|
|
|
11807
11881
|
"../core/dist/tools/unpin-contact.js"() {
|
|
11808
11882
|
"use strict";
|
|
11809
11883
|
init_tool_descriptions_generated();
|
|
11884
|
+
init_pin_contact();
|
|
11810
11885
|
unpinContact = {
|
|
11811
11886
|
name: "leadbay_unpin_contact",
|
|
11812
11887
|
description: leadbay_unpin_contact,
|
|
@@ -11830,7 +11905,13 @@ var init_unpin_contact = __esm({
|
|
|
11830
11905
|
additionalProperties: false
|
|
11831
11906
|
},
|
|
11832
11907
|
execute: async (client, params, _ctx) => {
|
|
11833
|
-
|
|
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
|
+
}
|
|
11834
11915
|
return { pinned: false, contact_id: params.contact_id, action: "unpinned" };
|
|
11835
11916
|
}
|
|
11836
11917
|
};
|
|
@@ -11873,39 +11954,59 @@ var init_update_contact = __esm({
|
|
|
11873
11954
|
// new value. execute forwards null verbatim; the backend accepts it.
|
|
11874
11955
|
job_title: {
|
|
11875
11956
|
type: ["string", "null"],
|
|
11876
|
-
description: "Contact job title. Pass null to
|
|
11957
|
+
description: "Contact job title. Omit it to leave it unchanged. Pass null to ERASE it \u2014 an erase rewrites the whole contact, so that call must also carry every other optional field, or it is refused."
|
|
11877
11958
|
},
|
|
11878
11959
|
linkedin_page: {
|
|
11879
11960
|
type: ["string", "null"],
|
|
11880
|
-
description: "Contact LinkedIn URL. Pass null to
|
|
11961
|
+
description: "Contact LinkedIn URL. Omit it to leave it unchanged. Pass null to ERASE it \u2014 an erase rewrites the whole contact, so that call must also carry every other optional field, or it is refused."
|
|
11881
11962
|
},
|
|
11882
11963
|
email: {
|
|
11883
11964
|
type: ["string", "null"],
|
|
11884
|
-
description: "Contact email. Pass null to
|
|
11965
|
+
description: "Contact email. Omit it to leave it unchanged. Pass null to ERASE it \u2014 an erase rewrites the whole contact, so that call must also carry every other optional field, or it is refused."
|
|
11885
11966
|
},
|
|
11886
11967
|
phone_number: {
|
|
11887
11968
|
type: ["string", "null"],
|
|
11888
|
-
description: "Contact phone (free-form). Pass null to
|
|
11969
|
+
description: "Contact phone (free-form). Omit it to leave it unchanged. Pass null to ERASE it \u2014 an erase rewrites the whole contact, so that call must also carry every other optional field, or it is refused."
|
|
11889
11970
|
}
|
|
11890
11971
|
},
|
|
11891
11972
|
required: ["contact_id", "first_name", "last_name"],
|
|
11892
11973
|
additionalProperties: false
|
|
11893
11974
|
},
|
|
11894
11975
|
execute: async (client, params, _ctx) => {
|
|
11976
|
+
const OPTIONAL = ["job_title", "linkedin_page", "email", "phone_number"];
|
|
11977
|
+
const asked = (f) => params[f] !== void 0;
|
|
11978
|
+
const clearing = OPTIONAL.filter((f) => params[f] === null);
|
|
11895
11979
|
const body = {
|
|
11896
11980
|
first_name: params.first_name,
|
|
11897
11981
|
last_name: params.last_name
|
|
11898
11982
|
};
|
|
11899
|
-
|
|
11900
|
-
|
|
11901
|
-
|
|
11902
|
-
|
|
11903
|
-
|
|
11904
|
-
|
|
11905
|
-
|
|
11906
|
-
|
|
11983
|
+
for (const f of OPTIONAL)
|
|
11984
|
+
if (asked(f))
|
|
11985
|
+
body[f] = params[f];
|
|
11986
|
+
if (clearing.length === 0) {
|
|
11987
|
+
const contact2 = await client.request("POST", `/contacts/${params.contact_id}/merge`, body);
|
|
11988
|
+
return {
|
|
11989
|
+
updated: true,
|
|
11990
|
+
contact_id: params.contact_id,
|
|
11991
|
+
contact: contact2,
|
|
11992
|
+
mode: "merge",
|
|
11993
|
+
preserved: OPTIONAL.filter((f) => !asked(f)),
|
|
11994
|
+
cleared: []
|
|
11995
|
+
};
|
|
11996
|
+
}
|
|
11997
|
+
const missing = OPTIONAL.filter((f) => !asked(f));
|
|
11998
|
+
if (missing.length > 0) {
|
|
11999
|
+
throw client.makeError("CONTACT_CLEAR_NEEDS_FULL_RECORD", `Clearing ${clearing.join(", ")} rewrites the whole contact, and ${missing.join(", ")} ${missing.length === 1 ? "was" : "were"} not supplied`, `Read the contact (leadbay_research_lead_by_id) and re-call with ALL of ${OPTIONAL.join(", ")} \u2014 current value to keep it, null to clear it. Omitting a field here would delete it.`, `POST /contacts/${params.contact_id}/update`);
|
|
12000
|
+
}
|
|
11907
12001
|
const contact = await client.request("POST", `/contacts/${params.contact_id}/update`, body);
|
|
11908
|
-
return {
|
|
12002
|
+
return {
|
|
12003
|
+
updated: true,
|
|
12004
|
+
contact_id: params.contact_id,
|
|
12005
|
+
contact,
|
|
12006
|
+
mode: "replace",
|
|
12007
|
+
preserved: [],
|
|
12008
|
+
cleared: clearing
|
|
12009
|
+
};
|
|
11909
12010
|
}
|
|
11910
12011
|
};
|
|
11911
12012
|
}
|
|
@@ -13926,7 +14027,8 @@ ${firm.short_description}`);
|
|
|
13926
14027
|
const ln = c.last_name ?? "";
|
|
13927
14028
|
const title = c.job_title ?? "\u2014";
|
|
13928
14029
|
const channel = c.email ?? c.phone_number ?? "\u2014";
|
|
13929
|
-
|
|
14030
|
+
const pin = c.pinned ? " \u{1F4CC}" : "";
|
|
14031
|
+
out.push(`- **${(fn + " " + ln).trim() || "(unknown)"}**${pin} \u2014 ${title} \xB7 ${channel}`);
|
|
13930
14032
|
}
|
|
13931
14033
|
}
|
|
13932
14034
|
const candidates = Array.isArray(contacts.candidates) ? contacts.candidates : [];
|
|
@@ -13938,7 +14040,8 @@ ${firm.short_description}`);
|
|
|
13938
14040
|
const ln = c.last_name ?? "";
|
|
13939
14041
|
const title = c.job_title ?? "\u2014";
|
|
13940
14042
|
const li = c.linkedin_page ? `LinkedIn` : "no LinkedIn";
|
|
13941
|
-
|
|
14043
|
+
const pin = c.pinned ? " \u{1F4CC}" : "";
|
|
14044
|
+
out.push(`- **${(fn + " " + ln).trim() || "(unknown)"}**${pin} \u2014 ${title} \xB7 ${li}`);
|
|
13942
14045
|
}
|
|
13943
14046
|
if (candidates.length > 10)
|
|
13944
14047
|
out.push(`- _${candidates.length - 10} more \u2026_`);
|
|
@@ -14150,7 +14253,7 @@ var init_research_lead_by_id = __esm({
|
|
|
14150
14253
|
},
|
|
14151
14254
|
contacts: {
|
|
14152
14255
|
type: "object",
|
|
14153
|
-
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.',
|
|
14154
14257
|
properties: {
|
|
14155
14258
|
reachable: { type: "array", items: { type: "object" } },
|
|
14156
14259
|
candidates: { type: "array", items: { type: "object" } }
|
|
@@ -14275,6 +14378,12 @@ var init_research_lead_by_id = __esm({
|
|
|
14275
14378
|
linkedin_page: normalizeLinkedinPage5(c.linkedin_page ?? null),
|
|
14276
14379
|
recommended: c.recommended,
|
|
14277
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,
|
|
14278
14387
|
source: "org"
|
|
14279
14388
|
});
|
|
14280
14389
|
const allContacts = [
|
|
@@ -27862,7 +27971,7 @@ var OAUTH_BASE_URLS = {
|
|
|
27862
27971
|
fr: "https://staging.api.leadbay.app"
|
|
27863
27972
|
}
|
|
27864
27973
|
};
|
|
27865
|
-
var VERSION = "0.33.
|
|
27974
|
+
var VERSION = "0.33.3";
|
|
27866
27975
|
var HELP = `
|
|
27867
27976
|
leadbay-mcp ${VERSION} \u2014 Leadbay Model Context Protocol server
|
|
27868
27977
|
|
package/dist/http-server.js
CHANGED
|
@@ -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
|
`;
|
|
@@ -8283,7 +8315,7 @@ Trigger phrases: "update this contact", "fix this contact's title", "change thei
|
|
|
8283
8315
|
|
|
8284
8316
|
Do NOT use for: "add a new contact to this company" \u2192 \`leadbay_add_contact\`; "remove / delete this contact" \u2192 \`leadbay_remove_contact\`; "get email/phone for a contact (enrichment)" \u2192 \`leadbay_enrich_titles\`; "fix an enriched contact's details" \u2192 \`leadbay_add_contact\`.
|
|
8285
8317
|
|
|
8286
|
-
Prefer when: user wants to change details on a contact that is in their own directory (\`source: "org"\`) \u2014 pass
|
|
8318
|
+
Prefer when: user wants to change details on a contact that is in their own directory (\`source: "org"\`) \u2014 pass its \`contact_id\`, first_name, last_name and only the fields being changed
|
|
8287
8319
|
|
|
8288
8320
|
Examples that SHOULD invoke this tool:
|
|
8289
8321
|
- "Update Jane's title to SVP Engineering."
|
|
@@ -8316,11 +8348,30 @@ Pass the contact's **own** \`contact_id\` \u2014 **not** the parent lead id.
|
|
|
8316
8348
|
|
|
8317
8349
|
A 404 from this tool almost always means a \`"paid"\` id was passed. Re-read the contact, check \`source\`, and do not retry the same id.
|
|
8318
8350
|
|
|
8319
|
-
|
|
8351
|
+
## Omitting a field keeps it. Erasing one takes an explicit \`null\`.
|
|
8352
|
+
|
|
8353
|
+
Send only what you are changing. Any field you leave out keeps its current value \u2014 you do NOT need to read the contact first and echo everything back.
|
|
8354
|
+
|
|
8355
|
+
\`\`\`
|
|
8356
|
+
{ contact_id, first_name, last_name, job_title: "CEO" }
|
|
8357
|
+
\u2192 title becomes CEO. email, phone and LinkedIn are untouched.
|
|
8358
|
+
\`\`\`
|
|
8359
|
+
|
|
8360
|
+
**To erase a field, pass it as \`null\`.** Because erasing rewrites the whole record, that call must carry ALL of \`job_title\`, \`linkedin_page\`, \`email\`, \`phone_number\` \u2014 current value for the ones to keep, \`null\` for the ones to erase. If any are missing the call is refused with \`CONTACT_CLEAR_NEEDS_FULL_RECORD\` rather than deleting them; read the contact with \`leadbay_research_lead_by_id\` and re-call.
|
|
8361
|
+
|
|
8362
|
+
\`\`\`
|
|
8363
|
+
{ contact_id, first_name, last_name, email: null,
|
|
8364
|
+
job_title: "CEO", phone_number: "+33\u2026", linkedin_page: "https://\u2026" }
|
|
8365
|
+
\u2192 email erased, everything else as given.
|
|
8366
|
+
\`\`\`
|
|
8367
|
+
|
|
8368
|
+
\`first_name\` + \`last_name\` are required on every call. The backend validates the contact's identity and rejects a body without them (\`invalid contact\`), so pass the current values when you are not changing the name.
|
|
8369
|
+
|
|
8370
|
+
The result tells you which happened: \`mode\` is \`merge\` or \`replace\`, \`preserved\` lists the fields left untouched, \`cleared\` lists the fields erased. **Check \`cleared\` is what you intended.**
|
|
8320
8371
|
|
|
8321
|
-
Backend: \`POST /contacts/{contact_id}/update\`
|
|
8372
|
+
Backend: \`POST /contacts/{contact_id}/merge\` when nothing is being erased, \`POST /contacts/{contact_id}/update\` when something is. Snake_case body; camel-case is rejected. Edits in place (same id).
|
|
8322
8373
|
|
|
8323
|
-
Returns \`{ updated: true, contact_id, contact: { id, first_name, last_name, job_title, linkedin_page, email, phone_number } }\`.
|
|
8374
|
+
Returns \`{ updated: true, contact_id, mode, preserved, cleared, contact: { id, first_name, last_name, job_title, linkedin_page, email, phone_number } }\`.
|
|
8324
8375
|
|
|
8325
8376
|
Requires: LEADBAY_MCP_WRITE=1 (MCP) or exposeWrite=true (OpenClaw).
|
|
8326
8377
|
`;
|
|
@@ -8629,6 +8680,10 @@ var getLeadProfile = {
|
|
|
8629
8680
|
job_title: c.job_title,
|
|
8630
8681
|
recommended: c.recommended,
|
|
8631
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,
|
|
8632
8687
|
source: "org"
|
|
8633
8688
|
})),
|
|
8634
8689
|
...paidContacts.map((c) => ({
|
|
@@ -8713,7 +8768,7 @@ var getContacts = {
|
|
|
8713
8768
|
properties: {
|
|
8714
8769
|
contacts: {
|
|
8715
8770
|
type: "array",
|
|
8716
|
-
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.",
|
|
8717
8772
|
items: {
|
|
8718
8773
|
type: "object",
|
|
8719
8774
|
properties: {
|
|
@@ -8725,6 +8780,14 @@ var getContacts = {
|
|
|
8725
8780
|
linkedin_page: { type: ["string", "null"] },
|
|
8726
8781
|
job_title: { type: ["string", "null"] },
|
|
8727
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
|
+
},
|
|
8728
8791
|
source: { type: "string", enum: ["org", "paid"] },
|
|
8729
8792
|
enrichment: {
|
|
8730
8793
|
type: ["object", "null"],
|
|
@@ -8783,6 +8846,10 @@ var getContacts = {
|
|
|
8783
8846
|
job_title: c.job_title,
|
|
8784
8847
|
recommended: c.recommended,
|
|
8785
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,
|
|
8786
8853
|
source: "org"
|
|
8787
8854
|
})),
|
|
8788
8855
|
...paidContacts.map((c) => ({
|
|
@@ -13912,6 +13979,7 @@ var removeContact = {
|
|
|
13912
13979
|
};
|
|
13913
13980
|
|
|
13914
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.';
|
|
13915
13983
|
var pinContact = {
|
|
13916
13984
|
name: "leadbay_pin_contact",
|
|
13917
13985
|
description: leadbay_pin_contact,
|
|
@@ -13935,7 +14003,13 @@ var pinContact = {
|
|
|
13935
14003
|
additionalProperties: false
|
|
13936
14004
|
},
|
|
13937
14005
|
execute: async (client, params, _ctx) => {
|
|
13938
|
-
|
|
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
|
+
}
|
|
13939
14013
|
return { pinned: true, contact_id: params.contact_id, action: "pinned" };
|
|
13940
14014
|
}
|
|
13941
14015
|
};
|
|
@@ -13964,7 +14038,13 @@ var unpinContact = {
|
|
|
13964
14038
|
additionalProperties: false
|
|
13965
14039
|
},
|
|
13966
14040
|
execute: async (client, params, _ctx) => {
|
|
13967
|
-
|
|
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
|
+
}
|
|
13968
14048
|
return { pinned: false, contact_id: params.contact_id, action: "unpinned" };
|
|
13969
14049
|
}
|
|
13970
14050
|
};
|
|
@@ -14000,39 +14080,59 @@ var updateContact = {
|
|
|
14000
14080
|
// new value. execute forwards null verbatim; the backend accepts it.
|
|
14001
14081
|
job_title: {
|
|
14002
14082
|
type: ["string", "null"],
|
|
14003
|
-
description: "Contact job title. Pass null to
|
|
14083
|
+
description: "Contact job title. Omit it to leave it unchanged. Pass null to ERASE it \u2014 an erase rewrites the whole contact, so that call must also carry every other optional field, or it is refused."
|
|
14004
14084
|
},
|
|
14005
14085
|
linkedin_page: {
|
|
14006
14086
|
type: ["string", "null"],
|
|
14007
|
-
description: "Contact LinkedIn URL. Pass null to
|
|
14087
|
+
description: "Contact LinkedIn URL. Omit it to leave it unchanged. Pass null to ERASE it \u2014 an erase rewrites the whole contact, so that call must also carry every other optional field, or it is refused."
|
|
14008
14088
|
},
|
|
14009
14089
|
email: {
|
|
14010
14090
|
type: ["string", "null"],
|
|
14011
|
-
description: "Contact email. Pass null to
|
|
14091
|
+
description: "Contact email. Omit it to leave it unchanged. Pass null to ERASE it \u2014 an erase rewrites the whole contact, so that call must also carry every other optional field, or it is refused."
|
|
14012
14092
|
},
|
|
14013
14093
|
phone_number: {
|
|
14014
14094
|
type: ["string", "null"],
|
|
14015
|
-
description: "Contact phone (free-form). Pass null to
|
|
14095
|
+
description: "Contact phone (free-form). Omit it to leave it unchanged. Pass null to ERASE it \u2014 an erase rewrites the whole contact, so that call must also carry every other optional field, or it is refused."
|
|
14016
14096
|
}
|
|
14017
14097
|
},
|
|
14018
14098
|
required: ["contact_id", "first_name", "last_name"],
|
|
14019
14099
|
additionalProperties: false
|
|
14020
14100
|
},
|
|
14021
14101
|
execute: async (client, params, _ctx) => {
|
|
14102
|
+
const OPTIONAL = ["job_title", "linkedin_page", "email", "phone_number"];
|
|
14103
|
+
const asked = (f) => params[f] !== void 0;
|
|
14104
|
+
const clearing = OPTIONAL.filter((f) => params[f] === null);
|
|
14022
14105
|
const body = {
|
|
14023
14106
|
first_name: params.first_name,
|
|
14024
14107
|
last_name: params.last_name
|
|
14025
14108
|
};
|
|
14026
|
-
|
|
14027
|
-
|
|
14028
|
-
|
|
14029
|
-
|
|
14030
|
-
|
|
14031
|
-
|
|
14032
|
-
|
|
14033
|
-
|
|
14109
|
+
for (const f of OPTIONAL)
|
|
14110
|
+
if (asked(f))
|
|
14111
|
+
body[f] = params[f];
|
|
14112
|
+
if (clearing.length === 0) {
|
|
14113
|
+
const contact2 = await client.request("POST", `/contacts/${params.contact_id}/merge`, body);
|
|
14114
|
+
return {
|
|
14115
|
+
updated: true,
|
|
14116
|
+
contact_id: params.contact_id,
|
|
14117
|
+
contact: contact2,
|
|
14118
|
+
mode: "merge",
|
|
14119
|
+
preserved: OPTIONAL.filter((f) => !asked(f)),
|
|
14120
|
+
cleared: []
|
|
14121
|
+
};
|
|
14122
|
+
}
|
|
14123
|
+
const missing = OPTIONAL.filter((f) => !asked(f));
|
|
14124
|
+
if (missing.length > 0) {
|
|
14125
|
+
throw client.makeError("CONTACT_CLEAR_NEEDS_FULL_RECORD", `Clearing ${clearing.join(", ")} rewrites the whole contact, and ${missing.join(", ")} ${missing.length === 1 ? "was" : "were"} not supplied`, `Read the contact (leadbay_research_lead_by_id) and re-call with ALL of ${OPTIONAL.join(", ")} \u2014 current value to keep it, null to clear it. Omitting a field here would delete it.`, `POST /contacts/${params.contact_id}/update`);
|
|
14126
|
+
}
|
|
14034
14127
|
const contact = await client.request("POST", `/contacts/${params.contact_id}/update`, body);
|
|
14035
|
-
return {
|
|
14128
|
+
return {
|
|
14129
|
+
updated: true,
|
|
14130
|
+
contact_id: params.contact_id,
|
|
14131
|
+
contact,
|
|
14132
|
+
mode: "replace",
|
|
14133
|
+
preserved: [],
|
|
14134
|
+
cleared: clearing
|
|
14135
|
+
};
|
|
14036
14136
|
}
|
|
14037
14137
|
};
|
|
14038
14138
|
|
|
@@ -15938,7 +16038,8 @@ ${firm.short_description}`);
|
|
|
15938
16038
|
const ln = c.last_name ?? "";
|
|
15939
16039
|
const title = c.job_title ?? "\u2014";
|
|
15940
16040
|
const channel = c.email ?? c.phone_number ?? "\u2014";
|
|
15941
|
-
|
|
16041
|
+
const pin = c.pinned ? " \u{1F4CC}" : "";
|
|
16042
|
+
out.push(`- **${(fn + " " + ln).trim() || "(unknown)"}**${pin} \u2014 ${title} \xB7 ${channel}`);
|
|
15942
16043
|
}
|
|
15943
16044
|
}
|
|
15944
16045
|
const candidates = Array.isArray(contacts.candidates) ? contacts.candidates : [];
|
|
@@ -15950,7 +16051,8 @@ ${firm.short_description}`);
|
|
|
15950
16051
|
const ln = c.last_name ?? "";
|
|
15951
16052
|
const title = c.job_title ?? "\u2014";
|
|
15952
16053
|
const li = c.linkedin_page ? `LinkedIn` : "no LinkedIn";
|
|
15953
|
-
|
|
16054
|
+
const pin = c.pinned ? " \u{1F4CC}" : "";
|
|
16055
|
+
out.push(`- **${(fn + " " + ln).trim() || "(unknown)"}**${pin} \u2014 ${title} \xB7 ${li}`);
|
|
15954
16056
|
}
|
|
15955
16057
|
if (candidates.length > 10)
|
|
15956
16058
|
out.push(`- _${candidates.length - 10} more \u2026_`);
|
|
@@ -16156,7 +16258,7 @@ var researchLeadById = {
|
|
|
16156
16258
|
},
|
|
16157
16259
|
contacts: {
|
|
16158
16260
|
type: "object",
|
|
16159
|
-
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.',
|
|
16160
16262
|
properties: {
|
|
16161
16263
|
reachable: { type: "array", items: { type: "object" } },
|
|
16162
16264
|
candidates: { type: "array", items: { type: "object" } }
|
|
@@ -16281,6 +16383,12 @@ var researchLeadById = {
|
|
|
16281
16383
|
linkedin_page: normalizeLinkedinPage5(c.linkedin_page ?? null),
|
|
16282
16384
|
recommended: c.recommended,
|
|
16283
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,
|
|
16284
16392
|
source: "org"
|
|
16285
16393
|
});
|
|
16286
16394
|
const allContacts = [
|
|
@@ -24629,7 +24737,7 @@ function parseWriteEnv(env = process.env) {
|
|
|
24629
24737
|
}
|
|
24630
24738
|
|
|
24631
24739
|
// src/http-server.ts
|
|
24632
|
-
var VERSION = true ? "0.33.
|
|
24740
|
+
var VERSION = true ? "0.33.3" : "0.0.0-dev";
|
|
24633
24741
|
var PORT = Number(process.env.PORT ?? 8080);
|
|
24634
24742
|
var HOST = process.env.HOST ?? "0.0.0.0";
|
|
24635
24743
|
var logger = {
|
package/dist/installer-gui.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@leadbay/mcp",
|
|
3
|
-
"version": "0.33.
|
|
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",
|