Skip to content

Commit 3d5dda7

Browse files
ArtyETH06claude
andcommitted
refactor(contacts): add via direct POST /leads/{id}/contacts, not the import pipeline
Live testing through Claude Desktop's actual server binary revealed the import-pipeline add path (leadbay_import_and_qualify) returns 401 on the test account — POST /imports is rejected even with a valid token, and the agent surfaced it as a misleading "LEADBAY_TOKEN is no longer valid" error. The direct endpoint the web UI uses — POST /leads/{id}/contacts — works with the SAME token (verified 200, contact created with its id). So: - New leadbay_add_contact composite write tool wrapping POST /leads/{id}/contacts. One call, no import/qualify quota, accepts first/last name + optional job_title/linkedin_page/email/phone. Pairs with leadbay_remove_contact (POST /contacts/{id}/archive). - Revert the import_and_qualify routing/recipe changes — the add-contact intent now routes to leadbay_add_contact instead. (Kept one incidental fix: a stale leadbay_research_lead -> leadbay_research_lead_by_id reference.) - research_lead_by_id anti-trigger now points at leadbay_add_contact. - index / _composite-file-names / TOOLS_WITH_ROUTING registration, WORKFLOWS row #15 + contract updated, new unit test. Verified live through Desktop's bin.js + token: add -> contact created (no 401); remove -> archived; company back to 0 contacts. Suite green (core 324, promptforge 16, mcp 366). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 50b248d commit 3d5dda7

10 files changed

Lines changed: 343 additions & 104 deletions

File tree

WORKFLOWS.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ The table is the human-readable index. The `yaml expected` + `yaml scenario` blo
2626
| 12 | **Lens extension — on-demand fill for bigger appetite** — "I want more leads on this lens / I need a bigger batch today" | `leadbay_extend_my_lens` | "I want more leads on this lens — bigger batch today" |
2727
| 13 | **Lens management — list / switch audiences** — "show me my lenses", "which audiences do I have", "switch to my Joinery lens" | `leadbay_my_lenses` | "Show me my lenses and switch to the Joinery one" |
2828
| 14 | **Lens creation — make a named audience** — "create a lens called X for sector Y", "set up a new audience" | `leadbay_new_lens` | "Create a lens called Joinery for the fintech sector" |
29-
| 15 | **Add a contact to a known company** — "this company has no contacts — add Jane Doe, here's her LinkedIn", "add this person I found to that lead" | `leadbay_import_and_qualify` (one `records[]` row, `CONTACT_*` fields keyed by parent `LEADBAY_ID`) | "Acme has no contacts — add Jane Doe, VP Eng, here's her LinkedIn" |
29+
| 15 | **Add a contact to a known company** — "this company has no contacts — add Jane Doe, here's her LinkedIn", "add this person I found to that lead" | `leadbay_add_contact` (direct `POST /leads/{id}/contacts`; pass `lead_id` + name + optional linkedin/title/email/phone) | "Acme has no contacts — add Jane Doe, VP Eng, here's her LinkedIn" |
3030
| 16 | **Remove a contact from a company** — "remove this contact", "delete that person, wrong one", "undo the contact I just added" | `leadbay_remove_contact` (archives by the contact's own `contact_id`) | "Remove Jane Doe from that company — I added her by mistake" |
3131

3232
---
@@ -241,11 +241,11 @@ prompt: "Set up a prospecting campaign for my team"
241241
workflow_name: Add a contact to a known company
242242
prompt_name: ~
243243
required_calls:
244-
- leadbay_import_and_qualify
244+
- leadbay_add_contact
245245
forbidden_calls:
246246
- leadbay_report_outreach
247247
success_criteria:
248-
- "called leadbay_import_and_qualify with one records[] row carrying CONTACT_* fields keyed by the parent company (LEADBAY_ID / LEAD_NAME / LEAD_WEBSITE)"
248+
- "called leadbay_add_contact with the parent company lead_id plus the person's name (and any linkedin/title given)"
249249
- "did NOT switch to an external CRM or claim Leadbay can't add contacts"
250250
- "did NOT call leadbay_report_outreach"
251251
```

packages/core/src/composite/_composite-file-names.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
// that audit.
1111
export const COMPOSITE_FILE_TOOL_NAMES: ReadonlySet<string> = new Set([
1212
"leadbay_account_status",
13+
"leadbay_add_contact",
1314
"leadbay_add_leads_to_campaign",
1415
"leadbay_adjust_audience",
1516
"leadbay_answer_clarification",
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import type { LeadbayClient } from "../client.js";
2+
import type { Tool, ToolContext } from "../types.js";
3+
import { leadbay_add_contact as ADD_CONTACT_DESCRIPTION } from "../tool-descriptions.generated.js";
4+
5+
interface AddContactParams {
6+
// Parent company: the lead's UUID. The created contact attaches here.
7+
lead_id: string;
8+
first_name: string;
9+
last_name: string;
10+
job_title?: string;
11+
// LinkedIn profile URL — the reporter's hunch (product#3703): often the
12+
// only thing a rep has for a person they found by hand.
13+
linkedin_page?: string;
14+
email?: string;
15+
phone_number?: string;
16+
}
17+
18+
interface CreatedContact {
19+
id: string;
20+
first_name: string | null;
21+
last_name: string | null;
22+
email: string | null;
23+
phone_number: string | null;
24+
linkedin_page: string | null;
25+
job_title: string | null;
26+
can_enrich?: boolean;
27+
recommended?: boolean;
28+
pinned?: boolean;
29+
}
30+
31+
interface AddContactResult {
32+
added: true;
33+
lead_id: string;
34+
contact: CreatedContact;
35+
}
36+
37+
/**
38+
* Add a single contact to a known company — the in-conversation
39+
* "create_contact" the reporter asked for (leadbay/product#3703).
40+
*
41+
* Backend route: `POST /1.5/leads/{leadId}/contacts` → 200 with the created
42+
* contact (incl. its new `id`). This is the SAME direct endpoint the Leadbay
43+
* web UI uses to add a contact — distinct from, and lighter than, the CSV
44+
* import pipeline (`leadbay_import_and_qualify`), which 401s on some accounts
45+
* and burns import/qualify quota. One call, no quota, works.
46+
*
47+
* The undo is `leadbay_remove_contact` (archives by the contact's own id).
48+
*/
49+
export const addContact: Tool<AddContactParams, AddContactResult> = {
50+
name: "leadbay_add_contact",
51+
description: ADD_CONTACT_DESCRIPTION,
52+
write: true,
53+
annotations: {
54+
title: "Add a contact",
55+
readOnlyHint: false,
56+
destructiveHint: false,
57+
idempotentHint: false,
58+
openWorldHint: true,
59+
},
60+
inputSchema: {
61+
type: "object",
62+
properties: {
63+
lead_id: {
64+
type: "string",
65+
description:
66+
"UUID of the parent company (lead) to attach the contact to. The contact is created on this company.",
67+
},
68+
first_name: { type: "string", description: "Contact first name." },
69+
last_name: { type: "string", description: "Contact last name." },
70+
job_title: { type: "string", description: "Contact job title (optional)." },
71+
linkedin_page: {
72+
type: "string",
73+
description: "Contact LinkedIn profile URL (optional).",
74+
},
75+
email: { type: "string", description: "Contact email (optional)." },
76+
phone_number: {
77+
type: "string",
78+
description: "Contact phone number (optional, free-form).",
79+
},
80+
},
81+
required: ["lead_id", "first_name", "last_name"],
82+
additionalProperties: false,
83+
},
84+
execute: async (
85+
client: LeadbayClient,
86+
params: AddContactParams,
87+
_ctx?: ToolContext,
88+
): Promise<AddContactResult> => {
89+
const body: Record<string, unknown> = {
90+
first_name: params.first_name,
91+
last_name: params.last_name,
92+
};
93+
if (params.job_title != null) body.job_title = params.job_title;
94+
if (params.linkedin_page != null) body.linkedin_page = params.linkedin_page;
95+
if (params.email != null) body.email = params.email;
96+
if (params.phone_number != null) body.phone_number = params.phone_number;
97+
98+
const contact = await client.request<CreatedContact>(
99+
"POST",
100+
`/leads/${params.lead_id}/contacts`,
101+
body,
102+
);
103+
return { added: true, lead_id: params.lead_id, contact };
104+
},
105+
};

packages/core/src/index.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ import { bulkQualifyLeads } from "./composite/bulk-qualify-leads.js";
9595
import { resolveImportRows } from "./composite/resolve-import-rows.js";
9696
import { importLeads } from "./composite/import-leads.js";
9797
import { importAndQualify } from "./composite/import-and-qualify.js";
98+
import { addContact } from "./composite/add-contact.js";
9899
import { removeContact } from "./composite/remove-contact.js";
99100
import { importStatus } from "./composite/import-status.js";
100101
import { qualifyStatus } from "./composite/qualify-status.js";
@@ -293,9 +294,12 @@ export const compositeWriteTools: Tool[] = [
293294
reportOutreach,
294295
importLeads,
295296
importAndQualify,
296-
// removeContact — the undo for the add-a-contact path. Soft-deletes
297-
// (archives) a single contact via POST /contacts/{id}/archive. Default
298-
// write surface so "remove this contact" works without the advanced gate.
297+
// addContact / removeContact — add or remove a single person on a known
298+
// company, in-conversation (product#3703). addContact wraps the direct
299+
// POST /leads/{id}/contacts endpoint (same one the web UI uses — NOT the
300+
// import pipeline, which 401s on some accounts). removeContact is the undo,
301+
// archiving via POST /contacts/{id}/archive. Default write surface.
302+
addContact,
299303
removeContact,
300304
// createCustomField is granular-shaped but file-import prompts depend on it
301305
// to preserve source-system links without requiring advanced-tool exposure.

packages/core/src/tool-descriptions.generated.ts

Lines changed: 45 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,48 @@ WHEN NOT TO USE: as a pre-flight gate before bulk ops — operations themselves
4646
`;
4747
// endregion: leadbay_account_status
4848

49+
// region: leadbay_add_contact
50+
export const leadbay_add_contact: string = `## WHEN TO USE
51+
52+
Trigger phrases: "add a contact to this company", "add this person to <company>", "create a contact from this LinkedIn URL", "this company has no contacts — add one", "I found someone on LinkedIn, add them to <lead>".
53+
54+
**Memory:** recall + capture via \`leadbay_agent_memory_*\` tools.
55+
56+
Do NOT use for: "import these companies / a CSV of leads and qualify them" → \`leadbay_import_and_qualify\`; "get email/phone for a contact already on the company" → \`leadbay_enrich_titles\`; "remove / delete this contact" → \`leadbay_remove_contact\`.
57+
58+
Prefer when: user wants to attach ONE known person to an already-identified company — pass the company's \`lead_id\` plus the person's name (+ optional linkedin_page/title/email/phone)
59+
60+
Examples that SHOULD invoke this tool:
61+
- "Acme has no suggested contacts — add Jane Doe, VP Eng, here's her LinkedIn."
62+
- "Add this person I found on LinkedIn to that company."
63+
- "Create a contact for John Smith, CFO, on this lead."
64+
65+
Examples that should NOT invoke this tool (sound similar, route elsewhere):
66+
- "Import these 40 domains from my CRM and qualify them."
67+
- "Get me the email for the contact already on this company."
68+
- "Remove that contact, it's the wrong person."
69+
70+
## RENDER (quick)
71+
72+
One-line confirmation: the contact's name + title now sits on the company.
73+
No table. If the contact has no email/phone yet, note it can be enriched.
74+
75+
---
76+
77+
Add a single contact (a person) to a known company — the in-conversation "create a contact" path. Use it when a rep has found someone (often just a LinkedIn URL) and wants them on an already-identified Leadbay company without leaving the chat.
78+
79+
Pass the parent company's \`lead_id\` plus the person's \`first_name\` + \`last_name\`. Everything else is optional: \`job_title\`, \`linkedin_page\`, \`email\`, \`phone_number\`.
80+
81+
Backend: \`POST /leads/{lead_id}/contacts\` → returns the created contact with its new \`id\`. This is the same direct endpoint the Leadbay web UI uses — one call, no import/qualify quota. (Distinct from \`leadbay_import_and_qualify\`, which is for importing *lists of companies*, not attaching a single person.)
82+
83+
The created contact starts unenriched — if it has no email/phone, enrich it via \`leadbay_enrich_titles\`. The undo is \`leadbay_remove_contact\` (pass the returned \`contact.id\`).
84+
85+
Returns \`{ added: true, lead_id, contact: { id, first_name, last_name, job_title, linkedin_page, email, phone_number, … } }\`.
86+
87+
Requires: LEADBAY_MCP_WRITE=1 (MCP) or exposeWrite=true (OpenClaw).
88+
`;
89+
// endregion: leadbay_add_contact
90+
4991
// region: leadbay_add_leads_to_campaign
5092
export const leadbay_add_leads_to_campaign: string = `## WHEN TO USE
5193
@@ -1132,53 +1174,7 @@ WHEN NOT TO USE: as the first read on a lead — the leadbay_research_lead_by_id
11321174
// endregion: leadbay_get_web_fetch
11331175

11341176
// region: leadbay_import_and_qualify
1135-
export const leadbay_import_and_qualify: string = `## WHEN TO USE
1136-
1137-
Trigger phrases: "add a contact to this company", "add this person to <company>", "create a contact from this LinkedIn URL", "this company has no contacts — add one", "I found someone on LinkedIn, add them to <lead>", "import these companies and qualify them", "import this list and run AI qualification".
1138-
1139-
**Memory:** recall + capture via \`leadbay_agent_memory_*\` tools.
1140-
1141-
Do NOT use for: "show me new leads / discovery" → \`leadbay_pull_leads\`; "deep dive on a single lead I already picked" → \`leadbay_research_lead_by_id\`; "get email/phone for a contact already on the company" → \`leadbay_enrich_titles\`.
1142-
1143-
Prefer when: user wants to attach a known person (esp. a LinkedIn URL) to an already-identified company — pass one \`records[]\` row with \`CONTACT_*\` fields keyed by the parent \`LEADBAY_ID\` (else \`LEAD_NAME\`/\`LEAD_WEBSITE\`)
1144-
1145-
Examples that SHOULD invoke this tool:
1146-
- "Acme has no suggested contacts — add Jane Doe, VP Eng, here's her LinkedIn."
1147-
- "Add this person I found on LinkedIn to that company."
1148-
- "Import these 40 domains from my CRM and qualify them."
1149-
1150-
Examples that should NOT invoke this tool (sound similar, route elsewhere):
1151-
- "Show me today's leads."
1152-
- "Tell me everything about that lead I just picked."
1153-
- "Get me the email for the contact already on this company."
1154-
1155-
## RENDER (quick)
1156-
1157-
Use the import-result layout: summarize imported/qualified vs not-imported
1158-
counts, then per-lead qualification answers. For a single added contact,
1159-
confirm the person now sits on the parent company.
1160-
1161-
---
1162-
1163-
Import + qualify leads in one call. Pass either \`domains: [{domain, name?}]\` (Mode A) OR \`records[]\` with \`mappings\` (Mode B). At least one mapped field must be LEADBAY_ID, CRM_ID, SIREN, LEAD_NAME, or LEAD_WEBSITE. Discover the org's mappable surface via \`leadbay_list_mappable_fields\`. For messy files, prefer the \`leadbay_import_file\` prompt which walks an agent through scan → resolve → preserve → commit phases.
1164-
1165-
**Add a single contact to a known company** (the "create_contact" path): this is just Mode B with one record. Map the parent company + \`CONTACT_*\` fields — the import **creates an org contact** on that company. **\`LEAD_NAME\` is required even when you pass \`LEADBAY_ID\`** — the import rejects the row with \`missing LEAD_NAME field\` otherwise. So always send both: \`LEADBAY_ID\` (the exact match key) AND \`LEAD_NAME\` (the company's display name). Minimal call:
1166-
1167-
\`\`\`
1168-
records: [{
1169-
LEADBAY_ID: "<lead uuid>", // exact match key for the existing company
1170-
LEAD_NAME: "SHOWROOM AUTO, LLC", // REQUIRED — send alongside LEADBAY_ID
1171-
CONTACT_FIRST_NAME: "Jane",
1172-
CONTACT_LAST_NAME: "Doe",
1173-
CONTACT_TITLE: "VP Eng", // optional
1174-
CONTACT_LINKEDIN: "https://www.linkedin.com/in/janedoe", // optional
1175-
CONTACT_EMAIL: "jane@acme.com", // optional
1176-
CONTACT_PHONE_NUMBER: "+1...", // optional
1177-
}]
1178-
mappings: { fields: { LEADBAY_ID: "LEADBAY_ID", LEAD_NAME: "LEAD_NAME", CONTACT_FIRST_NAME: "CONTACT_FIRST_NAME", /* … */ } }
1179-
\`\`\`
1180-
1181-
If you don't have the \`LEADBAY_ID\`, key on \`LEAD_NAME\` (+ optional \`LEAD_WEBSITE\`) alone and the import fuzzy-matches to the existing company. Repeating the same parent across rows adds multiple contacts to that one company. After the contact lands, enrich its email/phone via \`leadbay_enrich_titles\`. Use this when a rep found a person (often just a LinkedIn URL) and wants them on an already-identified Leadbay company without leaving the conversation.
1177+
export const leadbay_import_and_qualify: string = `Import + qualify leads in one call. Pass either \`domains: [{domain, name?}]\` (Mode A) OR \`records[]\` with \`mappings\` (Mode B). At least one mapped field must be LEADBAY_ID, CRM_ID, SIREN, LEAD_NAME, or LEAD_WEBSITE. Discover the org's mappable surface via \`leadbay_list_mappable_fields\`. For messy files, prefer the \`leadbay_import_file\` prompt which walks an agent through scan → resolve → preserve → commit phases.
11821178
11831179
WHEN TO USE: agent has a list of companies (domains, or CSV-shaped rows from the user's CRM) and wants the full AI qualification — qualification answers, web-research signals — without orchestrating import + bulk_qualify_leads + lead_profile chains by hand.
11841180
@@ -2687,7 +2683,7 @@ Trigger phrases: "tell me about this lead", "deep dive on the lead I just picked
26872683
26882684
**Memory:** recall + capture via \`leadbay_agent_memory_*\` tools.
26892685
2690-
Do NOT use for: "company name without lead id" → \`leadbay_research_lead_by_name_fuzzy\`; "draft outreach for <Contact>" → \`leadbay_prepare_outreach\`; "add a contact to this company" → \`leadbay_import_and_qualify\`.
2686+
Do NOT use for: "company name without lead id" → \`leadbay_research_lead_by_name_fuzzy\`; "draft outreach for <Contact>" → \`leadbay_prepare_outreach\`; "add a contact to this company" → \`leadbay_add_contact\`.
26912687
26922688
Prefer when: user picked a row and you have its UUID; pass \`leadId\`
26932689
@@ -3361,6 +3357,7 @@ This tool MUTATES state. The caller (agent or human-in-the-loop) is responsible
33613357
// Map for legacy callers; prefer importing the named constant directly.
33623358
export const TOOL_DESCRIPTIONS = {
33633359
leadbay_account_status,
3360+
leadbay_add_contact,
33643361
leadbay_add_leads_to_campaign,
33653362
leadbay_add_note,
33663363
leadbay_adjust_audience,

0 commit comments

Comments
 (0)