Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
# Changelog

## 0.33.3 — 2026-09-02 — Pinning a contact says what it can and cannot do

- **Pinning someone Leadbay only suggested no longer looks like a breakage.**
You can pin the people already in your contact list. Someone Leadbay has
suggested for a company but not yet enriched cannot be pinned. The assistant
used to get back a bare "contact not found", read it as Leadbay being broken,
and try again several times. It now knows the difference, says so, and tells
you how to get that person into your list.
- **Pinning does not decide who gets enriched.** Asking for the managing
director rather than the president is a matter of which job title you ask
Leadbay to enrich, not which contact you pin. The old wording suggested
otherwise and the assistant believed it.
- **You can see who is pinned.** Whether a contact is pinned, and whether
Leadbay pinned them for you rather than you doing it, now comes back with the
contact instead of having to be inferred.

## 0.33.2 — 2026-09-02 — Correcting a contact no longer wipes their email

- **Fixing one detail on a contact used to delete the others.** Asking the
Expand Down
18 changes: 15 additions & 3 deletions packages/core/src/composite/research-lead-by-id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,8 @@ export function renderResearchLeadMarkdown(
const ln = (c.last_name ?? "") as string;
const title = c.job_title ?? "—";
const channel = c.email ?? c.phone_number ?? "—";
out.push(`- **${(fn + " " + ln).trim() || "(unknown)"}** — ${title} · ${channel}`);
const pin = c.pinned ? " 📌" : "";
out.push(`- **${(fn + " " + ln).trim() || "(unknown)"}**${pin} — ${title} · ${channel}`);
}
}
const candidates = Array.isArray(contacts.candidates)
Expand All @@ -135,7 +136,12 @@ export function renderResearchLeadMarkdown(
const ln = (c.last_name ?? "") as string;
const title = c.job_title ?? "—";
const li = c.linkedin_page ? `LinkedIn` : "no LinkedIn";
out.push(`- **${(fn + " " + ln).trim() || "(unknown)"}** — ${title} · ${li}`);
// An org contact with no email/phone is unreachable, so it lands here
// rather than in `reachable` — but it is still pinnable, and may be
// pinned right now. Mark it in both partitions or the pin disappears
// from the rendering for exactly the contacts that have no channel yet.
const pin = c.pinned ? " 📌" : "";
out.push(`- **${(fn + " " + ln).trim() || "(unknown)"}**${pin} — ${title} · ${li}`);
}
if (candidates.length > 10) out.push(`- _${candidates.length - 10} more …_`);
}
Expand Down Expand Up @@ -360,7 +366,7 @@ export const researchLeadById: Tool<ResearchLeadByIdParams> = {
contacts: {
type: "object",
description:
"Two-tier contact set, partitioned by reachability — 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 — 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 — a `\"paid\"` id returns NOT_FOUND there.",
"Two-tier contact set, partitioned by reachability — 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 — 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 — 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.",
properties: {
reachable: { type: "array", items: { type: "object" } },
candidates: { type: "array", items: { type: "object" } },
Expand Down Expand Up @@ -531,6 +537,12 @@ export const researchLeadById: Tool<ResearchLeadByIdParams> = {
linkedin_page: normalizeLinkedinPage(c.linkedin_page ?? null),
recommended: c.recommended,
enrichment_done: true,
// Pin state exists on org contacts only — mirror the backend, which
// omits it entirely from PaidContactPayload. `pinned` is what makes a
// pin readable at all; without it the agent can only infer the pin from
// `recommended`, which also moves for non-pin reasons.
pinned: c.pinned ?? false,
pinned_by_ai: c.pinned_by_ai ?? false,
source: "org" as const,
});
const allContacts: Array<ReturnType<typeof shapePaid> | ReturnType<typeof shapeOrg>> = [
Expand Down
40 changes: 36 additions & 4 deletions packages/core/src/tool-descriptions.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2592,7 +2592,7 @@ Trigger phrases: "pin this contact", "mark this person as priority", "make this

Do NOT use for: "unpin / remove the pin" → \`leadbay_unpin_contact\`; "add a contact to this company" → \`leadbay_add_contact\`; "remove / delete this contact" → \`leadbay_remove_contact\`.

Prefer when: user wants ONE person flagged as the priority on a company — pass that contact's own \`contact_id\`
Prefer when: user wants ONE person flagged as the priority on a company — pass that contact's own \`contact_id\`, and ONLY a \`source:"org"\` contact can be pinned (a \`source:"paid"\` candidate returns 'contact not found')

Examples that SHOULD invoke this tool:
- "Pin Jane Doe as the main contact on this company."
Expand All @@ -2614,9 +2614,24 @@ Pin a single contact on a company so it surfaces first as a priority / favourite

Pass the contact's **own** \`contact_id\` (the \`id\` field on a contact object from \`leadbay_research_lead_by_id\` or a contacts list) — **not** the parent lead id.

**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:

- \`source: "org"\` — 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).
- \`source: "paid"\` — 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.

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.

To pin someone who is currently only a candidate, first make them an org contact:

- \`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.
- Or add them directly with \`leadbay_add_contact\`, which returns the new org contact's \`id\` — that id is pinnable immediately.

**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énéral rather than the Président" is \`leadbay_enrich_titles\` with the wanted title — not a pin. Pinning first and enriching after changes nothing about who gets enriched.


Backend: \`POST /contacts/{contact_id}/pin\` → 204. Idempotent. The inverse is \`leadbay_unpin_contact\`.

Returns \`{ pinned: true, contact_id, action: "pinned" }\`.
Returns \`{ pinned: true, contact_id, action: "pinned" }\`. To read the resulting state back, re-call \`leadbay_research_lead_by_id\` — the pinned contact's \`pinned\` flips to \`true\` and it becomes the lead's \`recommended\` contact.

Requires: LEADBAY_MCP_WRITE=1 (MCP) or exposeWrite=true (OpenClaw).
`;
Expand Down Expand Up @@ -4657,7 +4672,7 @@ Trigger phrases: "unpin this contact", "remove the pin from this contact", "this

Do NOT use for: "pin / mark as priority" → \`leadbay_pin_contact\`; "remove / delete this contact" → \`leadbay_remove_contact\`.

Prefer when: user wants to clear the pinned flag on a contact (but keep the contact) — pass that contact's own \`contact_id\`
Prefer when: user wants to clear the pinned flag on a contact (but keep the contact) — pass that contact's own \`contact_id\`, and ONLY a \`source:"org"\` contact can be unpinned (a \`source:"paid"\` candidate returns 'contact not found')

Examples that SHOULD invoke this tool:
- "Unpin Jane Doe — she's not the priority anymore."
Expand All @@ -4680,9 +4695,26 @@ Unpin a single contact on a company — clears its priority / favourite flag. Th

Pass the contact's **own** \`contact_id\` — not the parent lead id.

**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:

- \`source: "org"\` — 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).
- \`source: "paid"\` — 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.

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.

To pin someone who is currently only a candidate, first make them an org contact:

- \`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.
- Or add them directly with \`leadbay_add_contact\`, which returns the new org contact's \`id\` — that id is pinnable immediately.

**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énéral rather than the Président" is \`leadbay_enrich_titles\` with the wanted title — not a pin. Pinning first and enriching after changes nothing about who gets enriched.


A \`source: "org"\` contact that was never pinned is a no-op here, not an error — the backend answers 204 either way. Check \`pinned\` on the contact before calling if you need to tell the user whether anything actually changed.

Backend: \`POST /contacts/{contact_id}/unpin\` → 204. Idempotent. The inverse is \`leadbay_pin_contact\`.

Returns \`{ pinned: false, contact_id, action: "unpinned" }\`.
Returns \`{ pinned: false, contact_id, action: "unpinned" }\`. To read the resulting state back, re-call \`leadbay_research_lead_by_id\` — the contact's \`pinned\` flips to \`false\` and the lead's \`recommended\` contact reverts to the title-matched default.

Requires: LEADBAY_MCP_WRITE=1 (MCP) or exposeWrite=true (OpenClaw).
`;
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/tools/add-contact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,11 @@ interface CreatedContact {
job_title: string | null;
can_enrich?: boolean;
recommended?: boolean;
// Pin state as the backend returns it. A freshly created org contact is
// never pinned, but the fields ship so the shape matches every other org
// contact the agent sees — and this id IS pinnable via leadbay_pin_contact.
pinned?: boolean;
pinned_by_ai?: boolean;
}

interface AddContactResult {
Expand Down
16 changes: 15 additions & 1 deletion packages/core/src/tools/get-contacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export const getContacts: Tool<GetContactsParams> = {
contacts: {
type: "array",
description:
"Merged org+paid contacts. Each: {id, first_name, last_name, email, phone_number, linkedin_page, job_title, recommended, enrichment, source:'org'|'paid'}.",
"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 — passing its id to leadbay_pin_contact / leadbay_unpin_contact returns NOT_FOUND.",
items: {
type: "object",
properties: {
Expand All @@ -46,6 +46,16 @@ export const getContacts: Tool<GetContactsParams> = {
linkedin_page: { type: ["string", "null"] },
job_title: { type: ["string", "null"] },
recommended: { type: "boolean" },
pinned: {
type: "boolean",
description:
"Someone flagged this person as the priority on the company. Present on source:'org' contacts only.",
},
pinned_by_ai: {
type: "boolean",
description:
"The pin came from Leadbay's AI rather than a human. Present on source:'org' contacts only.",
},
source: { type: "string", enum: ["org", "paid"] },
enrichment: {
type: ["object", "null"],
Expand Down Expand Up @@ -123,6 +133,10 @@ export const getContacts: Tool<GetContactsParams> = {
job_title: c.job_title,
recommended: c.recommended,
enrichment: c.enrichment,
// Org contacts only — the backend's PaidContactPayload carries no
// pin state, because a paid candidate cannot be pinned.
pinned: c.pinned ?? false,
pinned_by_ai: c.pinned_by_ai ?? false,
source: "org" as const,
})),
...paidContacts.map((c) => ({
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/tools/get-lead-profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,10 @@ export const getLeadProfile: Tool<GetLeadProfileParams> = {
job_title: c.job_title,
recommended: c.recommended,
enrichment: c.enrichment,
// Org contacts only — the backend's PaidContactPayload carries no pin
// state, because a paid candidate cannot be pinned.
pinned: c.pinned ?? false,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This shaping site gets the same pinned/pinned_by_ai passthrough as research-lead-by-id.ts and get-contacts.ts, but unlike those two, it has no test coverage here — contact-pin-state-passthrough.test.ts only exercises research_lead_by_id and get_contacts. Since existing test files can't be modified, this needs a new test (or an addition to the new pin-state test file) covering get_lead_profile.

pinned_by_ai: c.pinned_by_ai ?? false,
source: "org" as const,
})),
...paidContacts.map((c) => ({
Expand Down
24 changes: 23 additions & 1 deletion packages/core/src/tools/pin-contact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@ import type { LeadbayClient } from "../client.js";
import type { Tool, ToolContext } from "../types.js";
import { leadbay_pin_contact as PIN_CONTACT_DESCRIPTION } from "../tool-descriptions.generated.js";

/**
* Replaces the client's generic 404 hint on the pin/unpin endpoints. Shared
* with unpin-contact.ts so both answer a NOT_FOUND the same way.
*/
export const 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 — " +
"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.";

interface PinContactParams {
// The contact's own UUID (the `id` on a contact object) — NOT the lead id.
contact_id: string;
Expand Down Expand Up @@ -46,7 +58,17 @@ export const pinContact: Tool<PinContactParams, PinContactResult> = {
params: PinContactParams,
_ctx?: ToolContext,
): Promise<PinContactResult> => {
await client.requestVoid("POST", `/contacts/${params.contact_id}/pin`);
try {
await client.requestVoid("POST", `/contacts/${params.contact_id}/pin`);
} catch (e: any) {
// The generic 404 hint is "Verify the ID is correct", which for this
// endpoint is wrong advice: the id IS correct, it is just a paid
// candidate rather than an org contact. Agents read that hint as "look
// it up again and retry" and hammer the endpoint (43 of 48 production
// pin calls in the 180 days to 2026-09-02 were this 404).
if (e?.code === "NOT_FOUND") throw { ...e, hint: NOT_PINNABLE_HINT };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not classify every contact 404 as a paid candidate

When contact_id is mistyped, stale, or belongs to a deleted org contact, this endpoint returns the same NOT_FOUND, but this branch replaces the accurate verification hint with one asserting that the ID is correct and directing the agent to enrich or add the person. Because the request carries no source information, the tool cannot distinguish those cases from a paid candidate; preserve both possible causes (while still discouraging blind retries) so agents do not conceal bad IDs or create duplicate contacts.

Useful? React with 👍 / 👎.

throw e;
}
return { pinned: true, contact_id: params.contact_id, action: "pinned" };
},
};
11 changes: 10 additions & 1 deletion packages/core/src/tools/unpin-contact.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { LeadbayClient } from "../client.js";
import type { Tool, ToolContext } from "../types.js";
import { leadbay_unpin_contact as UNPIN_CONTACT_DESCRIPTION } from "../tool-descriptions.generated.js";
import { NOT_PINNABLE_HINT } from "./pin-contact.js";

interface UnpinContactParams {
// The contact's own UUID (the `id` on a contact object) — NOT the lead id.
Expand Down Expand Up @@ -46,7 +47,15 @@ export const unpinContact: Tool<UnpinContactParams, UnpinContactResult> = {
params: UnpinContactParams,
_ctx?: ToolContext,
): Promise<UnpinContactResult> => {
await client.requestVoid("POST", `/contacts/${params.contact_id}/unpin`);
try {
await client.requestVoid("POST", `/contacts/${params.contact_id}/unpin`);
} catch (e: any) {
// Same reasoning as pin-contact.ts: the generic "Verify the ID is
// correct" hint sends the agent into a retry loop on an id that can
// never resolve here.
if (e?.code === "NOT_FOUND") throw { ...e, hint: NOT_PINNABLE_HINT };
throw e;
}
return { pinned: false, contact_id: params.contact_id, action: "unpinned" };
},
};
6 changes: 6 additions & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,12 @@ export interface ContactPayload {
job_title: string | null;
recommended: boolean;
enrichment: ContactEnrichment | null;
// Pin state, straight off the backend's ContactPayload. Optional here only
// because older API builds predate the fields; the current backend always
// emits both on org contacts and NEITHER on paid ones (PaidContactPayload
// has no pin state — a paid candidate cannot be pinned).
pinned?: boolean;
pinned_by_ai?: boolean;
}

export interface BillingStatePayload {
Expand Down
Loading
Loading