diff --git a/WORKFLOWS.md b/WORKFLOWS.md index fc04a22..85c6e4f 100644 --- a/WORKFLOWS.md +++ b/WORKFLOWS.md @@ -46,6 +46,10 @@ The table is the human-readable index. The `yaml expected` + `yaml scenario` blo | 32 | **Build an interactive artifact** — "build me a call sheet / interactive lead board with a campaign dropdown, notes, statuses, likes per lead" — the agent fetches headless view-models + usage guide via `leadbay_artifact_kit`, then assembles a single-file HTML artifact whose `lb.field`/`lb.action` view-models POPULATE a dropdown from `leadbay_list_campaigns` and submit `leadbay_report_outreach` / `leadbay_add_leads_to_campaign` / `leadbay_like_lead` (carrying `verification` + `_triggered_by` where required); the artifact owns all rendering | `leadbay_artifact_kit` *(no dedicated prompt)* | "Build me an interactive call sheet for these leads." | | 33 | **Manager team-activity view** — "how is my team doing", "top performers this month", "activity by rep" — `leadbay_team_activity` returns a per-rep leaderboard (`reps`, sorted by `total_activities`) + an activity time-series (`trend`) for a look-back window, the data behind the web Dashboard-Manager screen. Feeds a manager artifact (`lb.teamActivity` → table + Chart.js); quota/remaining stays on `leadbay_account_status` | `leadbay_team_activity` *(no dedicated prompt)* | "How is my team doing this month?" | | 34 | **Campaign builder from scratch (solo)** — "build me a campaign from scratch" — one guided flow: discover on the active lens → qualify/pick a cohort → enrich the BUYER PERSONA of the user's product (revenue org, not seniority) with a coverage guarantee → persist via `leadbay_create_campaign` → render the ready-to-work `leadbay_campaign_call_sheet` view, then hand off to `leadbay_work_campaign`. Distinct from the team flow (`leadbay_setup_team_prospecting`) and the work-an-existing-one flow (`leadbay_work_campaign`). | `leadbay_build_campaign` | *(multi-turn — see `turns:` contract)* | +| 35 | **Org qualification methods** — "what qualification questions does Leadbay use", "how are my leads qualified" — retrieve the org-level AI-agent question catalog | `leadbay_get_qualification_methods` | "What qualification questions does Leadbay use to score my leads?" | +| 36 | **Per-lead custom-field values** — "what custom fields are on this lead", "show the CRM custom field values for " — retrieve the custom-field VALUES stored on one lead (distinct from the definitions catalog in `leadbay_list_mappable_fields`) | `leadbay_get_lead_custom_fields` | "What custom field values are stored on this lead?" | +| 37 | **Modify qualification methods** — "add a qualification question", "remove the X question", "change my qualification questions" — write the org's AI-agent questions. Enforces the max-5 cap and gates removals behind a confirm; does not invent or silently drop questions | `leadbay_set_qualification_methods` | "Remove the qualification question 'hghg', then add it back exactly as it was." | +| 38 | **Modify custom fields** — "create a custom field", "rename the X field", "delete the Y field" — manage the org CRM custom-field catalog. Update renames/retypes in place; delete is destructive and gated behind a confirm | `leadbay_create_custom_field`, `leadbay_update_custom_field`, `leadbay_delete_custom_field` | "Create a custom field called 'Eval Probe Field', then rename it to 'Eval Probe Renamed', then delete it." | --- @@ -506,6 +510,82 @@ success_criteria: prompt: "Create a group for menuisiers, pergolas, vérandas" ``` +```yaml expected +workflow_name: Org qualification methods +prompt_name: ~ +required_calls: + - leadbay_get_qualification_methods +forbidden_calls: + - leadbay_research_lead_by_id + - leadbay_get_taste_profile +success_criteria: + - "called leadbay_get_qualification_methods at least once" + - "listed the org's qualification questions returned by the tool, verbatim (did not invent or reword them)" + - "did NOT fabricate a per-lead score or answer — these are org-level questions, not a single lead's responses" + - "did NOT call leadbay_research_lead_by_id or leadbay_get_taste_profile (this is the focused org-level questions tool)" +``` + +```yaml scenario +prompt: "What qualification questions does Leadbay use to score my leads?" +``` + +```yaml expected +workflow_name: Per-lead custom-field values +prompt_name: ~ +required_calls: + - leadbay_get_lead_custom_fields +forbidden_calls: + - leadbay_list_mappable_fields +success_criteria: + - "called leadbay_get_lead_custom_fields with a lead id (discovering a lead first if needed)" + - "reported the lead's custom-field VALUES from the tool result — or, when the result is empty, correctly stated the lead/org has no custom-field values set (did not invent fields or values)" + - "did NOT call leadbay_list_mappable_fields — that returns field DEFINITIONS (the catalog), not a lead's values" +``` + +```yaml scenario +prompt: "Pull one of my leads and show me its CRM custom field values." +``` + +```yaml expected +workflow_name: Modify qualification methods +prompt_name: ~ +required_calls: + - leadbay_set_qualification_methods +forbidden_calls: + - leadbay_create_custom_field +success_criteria: + - "removed the named question via leadbay_set_qualification_methods (remove mode) and then re-added it — a round-trip that nets back to the original set" + - "honored the confirm gate on the removal (re-called with confirm:true after the safety preview, since removing shrinks the list) rather than ignoring it" + - "reported each step truthfully from the tool result (removed N→N-1, re-added N-1→N) without inventing a change the tool did not return" + - "only touched the single named question; did NOT drop or rewrite the OTHER questions, and did NOT call leadbay_create_custom_field" +# Self-restoring by construction: the scenario removes a question then adds the +# SAME text back, so the org's question set is identical before and after. The +# eval harness ALSO snapshots + restores the questions around the run as a +# backstop. Never leaves the live org mutated. +``` + +```yaml scenario +prompt: "Remove the qualification question 'hghg', then add it back exactly as it was." +``` + +```yaml expected +workflow_name: Modify custom fields +prompt_name: ~ +required_calls: + - leadbay_create_custom_field + - leadbay_update_custom_field + - leadbay_delete_custom_field +success_criteria: + - "created the field, then renamed it via leadbay_update_custom_field, then deleted it via leadbay_delete_custom_field — using the field id returned by create, not a guessed id" + - "the final delete actually completed (passed confirm:true, or confirmed after the safety preview) so the throwaway field does not linger" + - "reported each step truthfully from tool results (created / renamed / deleted) without inventing ids or claiming a change the tool did not return" + - "did NOT touch or delete any OTHER custom field — only the one it just created" +``` + +```yaml scenario +prompt: "Create a custom field called 'Eval Probe Field', then rename it to 'Eval Probe Renamed', then delete it." +``` + #### Workflow 30 — Account status: silent on unreadable quota ```yaml expected diff --git a/packages/core/src/composite/_composite-file-names.ts b/packages/core/src/composite/_composite-file-names.ts index dbfc765..61f1acf 100644 --- a/packages/core/src/composite/_composite-file-names.ts +++ b/packages/core/src/composite/_composite-file-names.ts @@ -22,6 +22,8 @@ export const COMPOSITE_FILE_TOOL_NAMES: ReadonlySet = new Set([ "leadbay_enrich_titles", "leadbay_extend_lens", "leadbay_followups_map", + "leadbay_get_lead_custom_fields", + "leadbay_get_qualification_methods", "leadbay_import_and_qualify", "leadbay_import_leads", "leadbay_import_status", @@ -42,6 +44,7 @@ export const COMPOSITE_FILE_TOOL_NAMES: ReadonlySet = new Set([ "leadbay_resolve_import_rows", "leadbay_scan_portfolio_signals", "leadbay_seed_candidates", + "leadbay_set_qualification_methods", "leadbay_team_activity", "leadbay_tour_plan", ]); diff --git a/packages/core/src/composite/get-lead-custom-fields.ts b/packages/core/src/composite/get-lead-custom-fields.ts new file mode 100644 index 0000000..df79993 --- /dev/null +++ b/packages/core/src/composite/get-lead-custom-fields.ts @@ -0,0 +1,158 @@ +import type { LeadbayClient } from "../client.js"; +import type { + Tool, + ToolContext, + LeadPayload, + LeadCustomFieldEntry, + CustomFieldDef, +} from "../types.js"; +import { withAgentMemoryMeta } from "../agent-memory/index.js"; + +import { leadbay_get_lead_custom_fields as GET_LEAD_CUSTOM_FIELDS_DESCRIPTION } from "../tool-descriptions.generated.js"; + +interface GetLeadCustomFieldsParams { + leadId: string; + lensId?: number; +} + +// A flattened, human-readable custom-field row for one lead. +interface CustomFieldValueRow { + id: string; + name: string | null; + type: string | null; + value: string | null; +} + +// Retrieve the CRM custom-field VALUES stored on a single lead. +// +// The lead-detail payload already embeds each field's definition under +// `custom_fields[].field` (verified live), so this is a pass-through + flatten +// — NO /crm/custom_fields join is needed on the happy path. The catalog is +// fetched ONLY as a fallback to name entries that arrive without an embedded +// `field` object (defensive; not observed in practice). +export const getLeadCustomFields: Tool = { + name: "leadbay_get_lead_custom_fields", + annotations: { + title: "Read a lead's custom-field values", + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + description: GET_LEAD_CUSTOM_FIELDS_DESCRIPTION, + inputSchema: { + type: "object", + properties: { + leadId: { type: "string", description: "Lead UUID (required)" }, + lensId: { + type: "number", + description: + "Lens id (escape hatch — normally omit; auto-resolves to the active lens)", + }, + }, + required: ["leadId"], + additionalProperties: false, + }, + outputSchema: { + type: "object", + properties: { + lead_id: { type: "string" }, + custom_fields: { + type: "array", + description: + "Custom-field VALUES on this lead. Each: {id, name, type, value}. Empty when the org has no custom fields or none are set on this lead.", + items: { type: "object" }, + }, + count: { type: "number" }, + region: { type: "string" }, + hint: { + type: "string", + description: + "Operator note — empty-state guidance, or a degradation note when the lead fetch returned entries without embedded definitions.", + }, + _meta: { type: "object" }, + }, + required: ["custom_fields", "lead_id"], + }, + execute: async ( + client: LeadbayClient, + params: GetLeadCustomFieldsParams, + ctx?: ToolContext + ) => { + const lensId = params.lensId ?? (await client.resolveDefaultLens()); + + // Mark the lead as seen+clicked in the user's lens (parity with + // get-lead-profile / research-lead-by-id). Fire-and-forget: a failure + // here must NOT break the field read. + void client + .request("POST", "/interactions", [ + { type: "LEAD_SEEN", leadId: params.leadId, lensId: String(lensId) }, + { type: "LEAD_CLICKED", leadId: params.leadId, lensId: String(lensId) }, + ]) + .catch(() => { + /* swallow — interaction logging is best-effort */ + }); + + const lead = await client.request( + "GET", + `/lenses/${lensId}/leads/${params.leadId}` + ); + + const entries: LeadCustomFieldEntry[] = lead.custom_fields ?? []; + + // Happy path: every entry is self-describing. Only when an entry lacks an + // embedded `field` do we reach for the catalog to name it. + const needsCatalog = entries.some((e) => !e?.field?.name); + let catalog: CustomFieldDef[] | null = null; + if (needsCatalog) { + try { + catalog = await client.request( + "GET", + "/crm/custom_fields" + ); + } catch (err: any) { + ctx?.logger?.warn?.( + `get_lead_custom_fields: catalog fallback failed: ${err?.message ?? err?.code ?? err}` + ); + } + } + const byId = new Map( + (catalog ?? []).map((f) => [f.id, f]) + ); + + const rows: CustomFieldValueRow[] = entries.map((e) => { + // Self-describing entry is the norm; fall back to the catalog only when + // `field` is absent but a bare id is present. + const bareId = (e as { id?: string }).id; + const id = e.field?.id ?? bareId ?? ""; + const def = e.field ? undefined : byId.get(String(id)); + return { + id: String(id), + name: e.field?.name ?? def?.name ?? null, + type: e.field?.type ?? def?.type ?? null, + value: e.value ?? null, + }; + }); + + let hint: string | undefined; + if (rows.length === 0) { + hint = + "This lead has no custom-field values. The org may have no custom fields defined — see leadbay_list_mappable_fields for the catalog, or set values via import (map a column to CUSTOM.)."; + } else if (needsCatalog && rows.some((r) => r.name === null)) { + hint = + "Some custom fields could not be named (the lead payload omitted definitions and the catalog fetch failed). Retry, or check leadbay_list_mappable_fields."; + } + + return withAgentMemoryMeta( + client, + { + lead_id: params.leadId, + custom_fields: rows, + count: rows.length, + region: client.region, + ...(hint ? { hint } : {}), + }, + ctx + ); + }, +}; diff --git a/packages/core/src/composite/get-qualification-methods.ts b/packages/core/src/composite/get-qualification-methods.ts new file mode 100644 index 0000000..a813f3c --- /dev/null +++ b/packages/core/src/composite/get-qualification-methods.ts @@ -0,0 +1,101 @@ +import type { LeadbayClient } from "../client.js"; +import type { Tool, ToolContext, AiAgentQuestionPayload } from "../types.js"; +import { withAgentMemoryMeta } from "../agent-memory/index.js"; + +import { leadbay_get_qualification_methods as GET_QUALIFICATION_METHODS_DESCRIPTION } from "../tool-descriptions.generated.js"; + +// Org-level "qualification methods" = the AI-agent questions Leadbay scores +// every lead against. Focused read tool: returns ONLY the question catalog +// (not the broader taste profile). Read-only itself; to MODIFY the questions +// use leadbay_set_qualification_methods (org-admin only, which every user is +// for their own org). For admins we surface that pointer in the hint. +export const getQualificationMethods: Tool> = { + name: "leadbay_get_qualification_methods", + annotations: { + title: "Read the org's qualification methods", + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + description: GET_QUALIFICATION_METHODS_DESCRIPTION, + inputSchema: { + type: "object", + properties: {}, + additionalProperties: false, + }, + outputSchema: { + type: "object", + properties: { + qualification_questions: { + type: "array", + description: + "Org-level questions Leadbay scores every lead against. Each: {question, created_at, lang}.", + items: { type: "object" }, + }, + count: { + type: "number", + description: "Number of qualification questions configured.", + }, + is_admin: { + type: "boolean", + description: + "Whether the current bearer-token holder is an org admin. Admins can modify the questions via leadbay_set_qualification_methods.", + }, + region: { type: "string" }, + hint: { + type: "string", + description: + "Operator note — admin edit pointer, or the empty-state message when no questions are configured.", + }, + _meta: { type: "object" }, + }, + required: ["qualification_questions"], + }, + execute: async ( + client: LeadbayClient, + _params: Record, + ctx?: ToolContext + ) => { + // resolveMe FIRST so its /users/me result is cached + gives us the org id. + // The role flag is best-effort (null on failure → not admin). + const me = await client.resolveMe().catch(() => null); + const isAdmin = me?.admin ?? false; + const orgId = me?.organization?.id ?? (await client.resolveOrgId()); + + // Fetch the questions endpoint DIRECTLY (not via resolveTasteProfile, which + // uses Promise.allSettled and substitutes [] for a rejected fetch). A + // transient backend/auth failure must surface as an ERROR here — never as a + // false "no questions configured", which could lead a caller to overwrite + // an org's real questions. + const questions = await client.request( + "GET", + `/organizations/${orgId}/ai_agent_questions` + ) ?? []; + + let hint: string | undefined; + if (questions.length === 0) { + hint = + "No qualification questions configured yet. Use leadbay_set_qualification_methods to add some, or leadbay_refine_prompt to shape the AI agent."; + } else if (isAdmin) { + hint = + "You're an org admin — use leadbay_set_qualification_methods to add, remove, or replace these questions."; + } + + return withAgentMemoryMeta( + client, + { + qualification_questions: questions.map((q) => ({ + question: q.question, + created_at: q.created_at, + lang: q.lang, + })), + count: questions.length, + is_admin: isAdmin, + region: client.region, + ...(hint ? { hint } : {}), + }, + ctx + ); + }, +}; diff --git a/packages/core/src/composite/set-qualification-methods.ts b/packages/core/src/composite/set-qualification-methods.ts new file mode 100644 index 0000000..81ecdf3 --- /dev/null +++ b/packages/core/src/composite/set-qualification-methods.ts @@ -0,0 +1,230 @@ +import type { LeadbayClient } from "../client.js"; +import type { Tool, ToolContext, AiAgentQuestionPayload } from "../types.js"; +import { withAgentMemoryMeta } from "../agent-memory/index.js"; + +import { leadbay_set_qualification_methods as SET_QUALIFICATION_METHODS_DESCRIPTION } from "../tool-descriptions.generated.js"; + +interface SetQualificationMethodsParams { + // Full replacement list. Mutually exclusive with add/remove. + questions?: string[]; + // Append these (deduped against current). Mutually exclusive with `questions`. + add?: string[]; + // Remove these exact question strings. Mutually exclusive with `questions`. + remove?: string[]; + // Required when the resulting list is SHORTER than the current one + // (a removal / shrinking replace drops questions the AI scores against). + confirm?: boolean; +} + +// Modify the org's qualification methods (the AI-agent questions every lead is +// scored against). Wire: POST /organizations/{orgId} with +// {ai_agent_lead_questions: [string, ...]} → 204. The endpoint is a FULL +// REPLACE, so this tool reads the current list, applies the requested change +// (set / add / remove), and posts the whole resulting array. Shrinking the +// list requires confirm:true (removing a question changes how every lead is +// scored). +export const setQualificationMethods: Tool = { + name: "leadbay_set_qualification_methods", + annotations: { + title: "Modify the org's qualification methods", + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: true, + }, + description: SET_QUALIFICATION_METHODS_DESCRIPTION, + write: true, + inputSchema: { + type: "object", + properties: { + questions: { + type: "array", + items: { type: "string" }, + description: + "Full replacement list of qualification questions (replaces ALL current questions). Mutually exclusive with add/remove.", + }, + add: { + type: "array", + items: { type: "string" }, + description: + "Questions to append to the current list (deduped). Mutually exclusive with `questions`.", + }, + remove: { + type: "array", + items: { type: "string" }, + description: + "Exact question strings to remove from the current list. Mutually exclusive with `questions`. A removal requires confirm:true.", + }, + confirm: { + type: "boolean", + description: + "Required when the resulting list is SHORTER than the current one (removing questions changes how every lead is scored). Without it, such a change is previewed and not applied.", + }, + }, + additionalProperties: false, + }, + outputSchema: { + type: "object", + properties: { + qualification_questions: { + type: "array", + description: "The questions AFTER the change. Each: {question}.", + items: { type: "object" }, + }, + count: { type: "number" }, + previous_count: { type: "number" }, + changed: { + type: "boolean", + description: "True when the list was actually written; false on a no-op or an unconfirmed shrink.", + }, + region: { type: "string" }, + hint: { + type: "string", + description: "Operator note — confirm prompt on a shrink, or a no-op explanation.", + }, + _meta: { type: "object" }, + }, + required: ["qualification_questions", "count", "changed"], + }, + execute: async ( + client: LeadbayClient, + params: SetQualificationMethodsParams, + ctx?: ToolContext + ) => { + const hasSet = Array.isArray(params.questions); + const hasAdd = Array.isArray(params.add) && params.add.length > 0; + const hasRemove = Array.isArray(params.remove) && params.remove.length > 0; + + if (hasSet && (hasAdd || hasRemove)) { + throw client.makeError( + "QUALIFICATION_METHODS_BAD_ARGS", + "`questions` (full replace) is mutually exclusive with add/remove", + "Pass EITHER `questions` (the full new list) OR `add`/`remove`, not both.", + "POST /organizations/{orgId}" + ); + } + if (!hasSet && !hasAdd && !hasRemove) { + throw client.makeError( + "QUALIFICATION_METHODS_NO_CHANGE", + "nothing to change — pass `questions`, `add`, or `remove`", + "Provide a full `questions` list, or `add`/`remove` entries.", + "POST /organizations/{orgId}" + ); + } + + const orgId = await client.resolveOrgId(); + + // Read the current list (the endpoint is full-replace, so add/remove need it). + const current = await client.request( + "GET", + `/organizations/${orgId}/ai_agent_questions` + ); + const currentQs = (current ?? []).map((q) => q.question); + + const norm = (s: string) => s.trim(); + let next: string[]; + if (hasSet) { + next = params.questions!.map(norm).filter((s) => s.length > 0); + } else { + next = [...currentQs]; + if (hasRemove) { + const drop = new Set(params.remove!.map(norm)); + next = next.filter((q) => !drop.has(norm(q))); + } + if (hasAdd) { + const seen = new Set(next.map(norm)); + for (const q of params.add!.map(norm)) { + if (q.length > 0 && !seen.has(q)) { + next.push(q); + seen.add(q); + } + } + } + } + + // De-dupe while preserving order (the backend stores the list verbatim). + const seen = new Set(); + next = next.filter((q) => { + const k = norm(q); + if (seen.has(k)) return false; + seen.add(k); + return true; + }); + + // Backend cap (verified live): an org may hold at most MAX_QUESTIONS + // qualification questions. Pre-check so the agent gets an actionable + // message instead of a raw 400 from the org POST. + const MAX_QUESTIONS = 5; + if (next.length > MAX_QUESTIONS) { + throw client.makeError( + "QUALIFICATION_METHODS_LIMIT", + `too many questions: ${next.length} (max ${MAX_QUESTIONS})`, + `Leadbay allows at most ${MAX_QUESTIONS} qualification questions. Remove some first (pass fewer in \`questions\`, or use \`remove\`), then add.`, + "POST /organizations/{orgId}" + ); + } + + const previousCount = currentQs.length; + const noChange = + next.length === currentQs.length && + next.every((q, i) => norm(q) === norm(currentQs[i] ?? "")); + + if (noChange) { + return withAgentMemoryMeta( + client, + { + qualification_questions: currentQs.map((q) => ({ question: q })), + count: currentQs.length, + previous_count: previousCount, + changed: false, + region: client.region, + hint: "No change — the resulting list is identical to the current one. Pass different `add`/`remove` entries, or call leadbay_get_qualification_methods to review the current questions.", + }, + ctx + ); + } + + // Dropping ANY existing question is destructive — require confirm. Gate on + // the actual removed set, not on count: a remove+add (or a `set`) that swaps + // one question for another keeps the count the same but still deletes a + // scoring question, so a count-only check (next.length < previousCount) + // would wrongly let it through without confirm. + const removed = currentQs.filter((q) => !next.some((n) => norm(n) === norm(q))); + if (removed.length > 0 && params.confirm !== true) { + return withAgentMemoryMeta( + client, + { + qualification_questions: currentQs.map((q) => ({ question: q })), + count: currentQs.length, + previous_count: previousCount, + changed: false, + region: client.region, + hint: `Re-call with confirm:true to apply. This would remove ${removed.length} question(s): ${removed + .map((q) => `"${q}"`) + .join(", ")}. Removing a question changes how every lead is scored.`, + }, + ctx + ); + } + + // 204 No Content on success. + await client.requestVoid("POST", `/organizations/${orgId}`, { + ai_agent_lead_questions: next, + }); + // The taste-profile cache holds the old questions — drop it so the next + // read reflects the change. + client.invalidateTasteProfile(); + + return withAgentMemoryMeta( + client, + { + qualification_questions: next.map((q) => ({ question: q })), + count: next.length, + previous_count: previousCount, + changed: true, + region: client.region, + }, + ctx + ); + }, +}; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e04cd21..6f28ef5 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -70,6 +70,8 @@ import { removePushback } from "./tools/remove-pushback.js"; import { previewBulkEnrichment } from "./tools/preview-bulk-enrichment.js"; import { launchBulkEnrichment } from "./tools/launch-bulk-enrichment.js"; import { createCustomField } from "./tools/create-custom-field.js"; +import { updateCustomField } from "./tools/update-custom-field.js"; +import { deleteCustomField } from "./tools/delete-custom-field.js"; import { likeLead } from "./tools/like-lead.js"; import { dislikeLead } from "./tools/dislike-lead.js"; // Contact management — single-call relay tools (granular-shaped); registered @@ -98,6 +100,9 @@ import { campaignProgression } from "./composite/campaign-progression.js"; import { campaignCallSheet } from "./composite/campaign-call-sheet.js"; import { researchLeadById } from "./composite/research-lead-by-id.js"; import { researchLeadByNameFuzzy } from "./composite/research-lead-by-name-fuzzy.js"; +import { getQualificationMethods } from "./composite/get-qualification-methods.js"; +import { setQualificationMethods } from "./composite/set-qualification-methods.js"; +import { getLeadCustomFields } from "./composite/get-lead-custom-fields.js"; import { accountHistory } from "./composite/account-history.js"; import { scanPortfolioSignals } from "./composite/scan-portfolio-signals.js"; import { recallOrderedTitles } from "./composite/recall-ordered-titles.js"; @@ -160,12 +165,14 @@ export { clearUserPrompt, pickClarification, dismissClarification, setEpilogueStatus, removeEpilogue, setPushback, removePushback, previewBulkEnrichment, launchBulkEnrichment, likeLead, dislikeLead, - createCustomField, + createCustomField, updateCustomField, deleteCustomField, // existing composite prepareOutreach, // new composite reads pullLeads, pullFollowups, followupsMap, tourPlan, listCampaigns, campaignProgression, campaignCallSheet, researchLeadById, researchLeadByNameFuzzy, + getQualificationMethods, getLeadCustomFields, + setQualificationMethods, accountHistory, recallOrderedTitles, accountStatus, scanPortfolioSignals, teamActivity, bulkEnrichStatus, qualifyStatus, importStatus, resolveImportRows, @@ -263,6 +270,15 @@ export const compositeReadTools: Tool[] = [ campaignCallSheet, researchLeadById, researchLeadByNameFuzzy, + // Org qualification methods — the AI-agent questions every lead is scored + // against. ALWAYS exposed (default surface): "how are my leads qualified" + // is a first-session question, and the underlying get_taste_profile is + // ADVANCED-gated. Read-only; no MCP edit endpoint exists (issue #3768). + getQualificationMethods, + // Per-lead custom-field VALUES. ALWAYS exposed: complements the always-on + // list_mappable_fields (which returns DEFINITIONS only). The lead payload + // embeds each field's definition, so no catalog join is needed (issue #3768). + getLeadCustomFields, // accountHistory layers FULL notes + activity timeline on top of research // so the agent can write the US4 "why has this dormant account resurfaced" // narrative in ONE call. ALWAYS exposed (compositeReadTools) — the underlying @@ -359,6 +375,15 @@ export const compositeWriteTools: Tool[] = [ // createCustomField is granular-shaped but file-import prompts depend on it // to preserve source-system links without requiring advanced-tool exposure. createCustomField, + // update/delete custom field — same default-surface rationale as create. + // delete is destructive (requires confirm:true). Both gated behind + // LEADBAY_MCP_WRITE=1 in MCP. + updateCustomField, + deleteCustomField, + // Modify the org's qualification methods (AI-agent questions). Full-replace + // endpoint (POST /organizations/{orgId} {ai_agent_lead_questions:[...]}); the + // tool reads current + applies add/remove/set. Shrinking requires confirm. + setQualificationMethods, // addNote is granular-shaped but file-import prompts depend on it to preserve // meaningful source-file notes after imports return lead ids. addNote, diff --git a/packages/core/src/tool-descriptions.generated.ts b/packages/core/src/tool-descriptions.generated.ts index 12bd6bc..caa3c53 100644 --- a/packages/core/src/tool-descriptions.generated.ts +++ b/packages/core/src/tool-descriptions.generated.ts @@ -914,6 +914,26 @@ WHEN NOT TO USE: pre-flight (the agent is not paying — the user is); for subsc `; // endregion: leadbay_create_topup_link +// region: leadbay_delete_custom_field +export const leadbay_delete_custom_field: string = `Delete an org-level CRM custom field. Use when the user explicitly wants to remove a custom field from their account — e.g. "delete the old 'Legacy Source' field". + +**This is destructive.** Removing the field drops its stored values from every lead and breaks any import mapping that targets \`CUSTOM.\`. For that reason the tool has a safety gate: + +- Call with \`id\` only → the tool returns the field that WOULD be deleted (\`{id, name, type, deleted:false, hint}\`) and changes nothing. Surface the hint to the user and ask them to confirm. +- Call with \`id\` + \`confirm:true\` → the field is deleted (\`{id, name, type, deleted:true}\`). + +\`id\` is the numeric custom-field id from \`leadbay_list_mappable_fields\` — NOT the \`CUSTOM.\` mapping value. + +WHEN TO USE: the user explicitly asks to delete/remove a custom field — and only fire with confirm:true after they confirm. + +WHEN NOT TO USE: to rename or retype a field (use leadbay_update_custom_field) or to create one (use leadbay_create_custom_field). + +### RENDERING + +Before deleting (no confirm), render the \`hint\` and ask the user to confirm — do NOT auto-confirm on the user's behalf. After a confirmed delete, acknowledge in one line: **"Deleted custom field #12 'Legacy Source'."** +`; +// endregion: leadbay_delete_custom_field + // region: leadbay_deselect_leads export const leadbay_deselect_leads: string = `Remove leads from the user's transient selection. @@ -1356,6 +1376,71 @@ WHEN NOT TO USE: when leadbay_research_lead_by_id has already been called — it `; // endregion: leadbay_get_lead_activities +// region: leadbay_get_lead_custom_fields +export const leadbay_get_lead_custom_fields: string = `## WHEN TO USE + +Trigger phrases: "what custom fields are on this lead", "show the CRM custom field values for ", "what's the on this lead", "get lead 's custom fields", "does this lead have any custom field values". + +**Memory:** recall + capture via \`leadbay_agent_memory_*\` tools. + +Do NOT use for: "what custom fields exist on my account" → \`leadbay_list_mappable_fields\`; "give me the full research dossier on this lead" → \`leadbay_research_lead_by_id\`. + +Prefer when: user wants the custom-field VALUES on ONE lead; pass \`leadId\` + +Examples that SHOULD invoke this tool: +- "What custom field values are stored on this lead?" +- "Show me the CRM custom fields for that company." + +Examples that should NOT invoke this tool (sound similar, route elsewhere): +- "What custom fields are defined on my account?" +- "Give me the full research breakdown on Acme Corp." + +## RENDER (quick) + +3-column markdown table: Field | Type | Value, one row per entry. When +\`custom_fields\` is empty, render the \`hint\` sentence instead of an empty +table. + +--- + +Retrieve the CRM custom-field **values** stored on a single lead — the actual +data (\`value\`) per custom field, not the field definitions. + +This is distinct from **leadbay_list_mappable_fields**, which returns the org's +custom-field *catalog* (the definitions: id/name/type, used for import +mapping). This tool answers "what does *this* lead hold for each custom field". + +Params: \`leadId\` (required UUID); \`lensId\` (optional escape hatch — normally +omit; auto-resolves to the active lens). + +Returns: + +- **\`custom_fields\`** — one row per value: \`{id, name, type, value}\`. The lead + payload embeds each field's definition, so rows are fully named without a + separate catalog lookup. Empty array when the org has no custom fields or + none are set on this lead. +- **\`count\`** — number of values. +- **\`hint\`** — empty-state guidance (points at \`leadbay_list_mappable_fields\` + and the import path), or a degradation note in the rare case a value arrived + without an embedded definition and the catalog fallback failed. + +Reading a lead marks it seen (LEAD_SEEN) the same way the research tools do, so +it ages out of the 'new' Discover view. + +Companion tools: **leadbay_list_mappable_fields** for the catalog/definitions; +**leadbay_research_lead_by_id** for the full lead dossier (signals, contacts, +qualification answers). + +### RENDERING + +Render \`custom_fields\` as a 3-column markdown table: **Field** (\`name\`, or the +\`id\` when name is null) · **Type** (\`type\`) · **Value** (\`value\`, or "—" when +null/empty). One row per entry, in the order returned. When \`custom_fields\` is +empty, render the \`hint\` sentence instead of an empty table. Don't fabricate +fields or values — render verbatim. +`; +// endregion: leadbay_get_lead_custom_fields + // region: leadbay_get_lead_notes export const leadbay_get_lead_notes: string = `Read existing notes on a lead — context the human team or prior agent runs have already captured. @@ -1401,6 +1486,73 @@ WHEN NOT TO USE: when the lead summary's \`prospecting_actions_count\` is 0. `; // endregion: leadbay_get_prospecting_actions +// region: leadbay_get_qualification_methods +export const leadbay_get_qualification_methods: string = `## WHEN TO USE + +Trigger phrases: "what are my qualification methods", "what questions does Leadbay ask about each lead", "show me the org qualification questions", "how are my leads being qualified", "what's the qualification criteria". + +**Memory:** recall + capture via \`leadbay_agent_memory_*\` tools. + +Do NOT use for: "how did this lead score on the qualification questions" → \`leadbay_research_lead_by_id\`; "show my ideal buyer profile and intent tags" → \`leadbay_get_taste_profile\`. + +Prefer when: user wants the ORG-level qualification questions catalog, no lead and no buyer profile + +Examples that SHOULD invoke this tool: +- "What qualification questions does Leadbay use to score my leads?" +- "Show me my org's qualification methods." + +Examples that should NOT invoke this tool (sound similar, route elsewhere): +- "How did Acme Corp answer the qualification questions?" +- "What's my ideal buyer profile?" + +## RENDER (quick) + +Numbered list of the questions (chat-native markdown), each one line. When +\`is_admin\` is true, append the \`hint\` as a footnote (points at +leadbay_set_qualification_methods for editing). When the list is empty, +render the \`hint\` instead. + +--- + +Retrieve the organization's **qualification methods** — the AI-agent questions +Leadbay scores every lead against. These are the org-level questions that drive +each lead's qualification boost; the per-lead ANSWERS to them surface inside +\`leadbay_research_lead_by_id\`. + +Returns: + +- **\`qualification_questions\`** — the catalog. Each: \`{question, created_at, + lang}\`. Ordered as the backend returns them. +- **\`count\`** — number of configured questions. +- **\`is_admin\`** — whether the current user is an org admin. Modifying the + questions (\`leadbay_set_qualification_methods\`) is an org-admin action; for + admins a \`hint\` points there. +- **\`hint\`** — operator note: the modify pointer, or an empty-state message + when no questions are configured. + +This tool only READS. To change the questions, use +**leadbay_set_qualification_methods** (add / remove / replace). The result is +cached on the client (it reuses the same taste-profile fetch as +\`leadbay_get_taste_profile\`), so repeated calls in a session are cheap. + +Companion tools: **leadbay_set_qualification_methods** to modify the questions; +**leadbay_get_taste_profile** when the user also wants the Ideal Buyer Profile + +purchase-intent tags; **leadbay_research_lead_by_id** for how a SPECIFIC lead +answered these questions; **leadbay_refine_prompt** to shape the AI agent's +behaviour. + +### RENDERING + +Render \`qualification_questions\` as a numbered list — one question per line, in +the order returned. Lead with a short heading like **"Qualification methods +(N)"**. When \`qualification_questions\` is empty, render the \`hint\` sentence +instead of an empty list. When \`is_admin\` is true and there are questions, +append the \`hint\` as a one-line footnote (points at +leadbay_set_qualification_methods). Do not invent questions or reword them — +render verbatim. +`; +// endregion: leadbay_get_qualification_methods + // region: leadbay_get_quota export const leadbay_get_quota: string = `Read remaining quota / spend across daily, weekly, and monthly windows for the org's resources (\`llm_completion\`, \`ai_rescore\`, \`web_fetch\`). Each entry shows \`current_units\` vs \`max_units\` and \`resets_at\`. @@ -3701,6 +3853,31 @@ This tool MUTATES state. The caller (agent or human-in-the-loop) is responsible `; // endregion: leadbay_set_pushback +// region: leadbay_set_qualification_methods +export const leadbay_set_qualification_methods: string = `Modify the organization's **qualification methods** — the AI-agent questions Leadbay scores every lead against. Use when the user wants to add, remove, or rewrite their qualification questions — e.g. "add a question about whether they run install crews", "remove the flooring question", "replace my questions with these three". + +The backend stores the list as a whole, so this tool reads the current questions and applies your change: + +- **\`add\`** — append questions (deduped against the current list). +- **\`remove\`** — drop the exact question strings you pass. +- **\`questions\`** — replace the ENTIRE list (mutually exclusive with add/remove). + +Leadbay allows **at most 5** qualification questions. If a change would exceed 5, the tool rejects with a clear limit message — remove some before adding. + +**Removing or shrinking the list is destructive** — it changes how every lead is scored. Any change that ends with FEWER questions than before requires \`confirm:true\`; without it the tool previews what would be removed and applies nothing. Adding questions does not need confirm. + +Returns the resulting \`{qualification_questions, count, previous_count, changed}\`. Phrase questions as the yes/no scoring prompts Leadbay uses (e.g. "Is the company likely to …?"). + +WHEN TO USE: the user wants to change the org's qualification questions. + +WHEN NOT TO USE: to READ the questions (use leadbay_get_qualification_methods) or to change a single lead's data. This is org-level — it affects scoring for ALL leads. + +### RENDERING + +After a change, confirm in one line — e.g. **"Added 1 question — you now score leads against 4 questions."** or **"Removed 'the flooring question' — 3 questions remain."** Then list the resulting questions as a numbered list. On an unconfirmed shrink, surface the \`hint\` (what would be removed) and ask the user to confirm — do NOT auto-confirm. +`; +// endregion: leadbay_set_qualification_methods + // region: leadbay_set_user_prompt export const leadbay_set_user_prompt: string = `Set the org's intelligence-refinement prompt — free-text instruction that steers Leadbay's lead recommendations beyond firmographics. Admin-only. Setting this clears any pending clarification and triggers a full intelligence regeneration (web search + high-reasoning). \`dry_run:true\` returns the call shape without contacting the backend. @@ -3942,6 +4119,23 @@ Requires: LEADBAY_MCP_WRITE=1 (MCP) or exposeWrite=true (OpenClaw). `; // endregion: leadbay_update_contact +// region: leadbay_update_custom_field +export const leadbay_update_custom_field: string = `Update an org-level CRM custom field in place. Use when the user wants to rename a custom field or change its type/config — e.g. "rename the 'Tier' field to 'Account Tier'" or "make the ARR field a PRICE in USD". + +Pass \`id\` (the numeric custom-field id from \`leadbay_list_mappable_fields\` — NOT the \`CUSTOM.\` mapping value) plus any of \`name\`, \`type\`, \`config\`. The update is a partial merge over the current definition: a rename-only call keeps the existing type; a retype-only call keeps the name. At least one of \`name\` / \`type\` / \`config\` is required. + +Type + config rules mirror creation: \`EXTERNAL_ID\` requires \`config.url_template\` containing \`{value}\`; \`PRICE\` requires \`config.currency\`; \`DATE\`/\`DATETIME\` may set \`config.format\`. Returns the updated \`{id, name, type, config, mapping_value}\`. + +WHEN TO USE: the user wants to change an existing custom field's name or type. + +WHEN NOT TO USE: to CREATE a new field (use leadbay_create_custom_field) or to DELETE one (use leadbay_delete_custom_field). To read a lead's custom-field values use leadbay_get_lead_custom_fields; to list the catalog use leadbay_list_mappable_fields. + +### RENDERING + +Confirm the change in one line naming the field and what changed, e.g. **"Renamed custom field #12 → 'Account Tier' (TEXT)."** or **"Updated #13 'ARR' → PRICE (USD)."** Don't dump the raw payload. +`; +// endregion: leadbay_update_custom_field + // region: leadbay_update_lens export const leadbay_update_lens: string = `Update lens metadata (name, description, mode flags). Does NOT change the audience filter — use leadbay_update_lens_filter for that. @@ -3989,6 +4183,7 @@ export const TOOL_DESCRIPTIONS = { leadbay_create_lens, leadbay_create_lens_draft, leadbay_create_topup_link, + leadbay_delete_custom_field, leadbay_deselect_leads, leadbay_discover_leads, leadbay_dislike_lead, @@ -4002,11 +4197,13 @@ export const TOOL_DESCRIPTIONS = { leadbay_get_enrichment_job_titles, leadbay_get_epilogue_responses, leadbay_get_lead_activities, + leadbay_get_lead_custom_fields, leadbay_get_lead_notes, leadbay_get_lead_profile, leadbay_get_lens_filter, leadbay_get_lens_scoring, leadbay_get_prospecting_actions, + leadbay_get_qualification_methods, leadbay_get_quota, leadbay_get_selection_ids, leadbay_get_taste_profile, @@ -4053,11 +4250,13 @@ export const TOOL_DESCRIPTIONS = { leadbay_set_active_lens, leadbay_set_epilogue_status, leadbay_set_pushback, + leadbay_set_qualification_methods, leadbay_set_user_prompt, leadbay_team_activity, leadbay_tour_plan, leadbay_unpin_contact, leadbay_update_contact, + leadbay_update_custom_field, leadbay_update_lens, leadbay_update_lens_filter, } as const; diff --git a/packages/core/src/tools/_custom-field-config.ts b/packages/core/src/tools/_custom-field-config.ts new file mode 100644 index 0000000..6ad5efa --- /dev/null +++ b/packages/core/src/tools/_custom-field-config.ts @@ -0,0 +1,53 @@ +import type { CustomCrmFieldConfig, CustomCrmFieldKind } from "../types.js"; + +// The backend's per-type config models are STRICT: PriceFieldConfig is exactly +// `{currency: String}`, Date/DateTimeFieldConfig is `{format: String?}`, +// ExternalIdFieldConfig is `{urlTemplate}`, and TEXT/NUMBER accept NO config. +// Any extra key (e.g. a stale `format` left over from a previous type, or our +// over-broad CustomCrmFieldConfig carrying both `url_template` and `urlTemplate`) +// makes the backend deserializer throw a 500 / "JSON deserialization error". +// +// sanitizeConfigForType narrows an arbitrary config object down to ONLY the +// key(s) the target type accepts, so create/update never send a shape the +// backend rejects. Returns null when the type takes no config (TEXT, NUMBER, +// or unknown), which the caller omits from the request body. +export function sanitizeConfigForType( + type: CustomCrmFieldKind, + rawConfig: CustomCrmFieldConfig | string | null | undefined +): CustomCrmFieldConfig | null { + if (!rawConfig) return null; + // LLMs frequently pass nested JSON as a STRING (e.g. config:'{"currency":"EUR"}') + // rather than an object. Parse it so the per-type narrowing below still works — + // otherwise PRICE/EXTERNAL_ID lose their required key and the backend 400s with + // "PRICE requires a currency config". Observed live. + let config: CustomCrmFieldConfig; + if (typeof rawConfig === "string") { + try { + const parsed = JSON.parse(rawConfig); + if (!parsed || typeof parsed !== "object") return null; + config = parsed as CustomCrmFieldConfig; + } catch { + return null; // unparseable string — no usable config + } + } else { + config = rawConfig; + } + switch (type) { + case "PRICE": + // PriceFieldConfig(val currency: String) — currency only. + return config.currency != null ? { currency: config.currency } : null; + case "DATE": + case "DATETIME": + // Date/DateTimeFieldConfig(val format: String?) — format only (nullable). + return "format" in config ? { format: config.format ?? null } : null; + case "EXTERNAL_ID": { + // ExternalIdFieldConfig — the backend wire key is url_template. We accept + // either casing from callers and normalize to the snake_case wire form. + const url = config.url_template ?? config.urlTemplate; + return url != null ? { url_template: url } : null; + } + default: + // TEXT, NUMBER, or any unknown kind — no config accepted. + return null; + } +} diff --git a/packages/core/src/tools/create-custom-field.ts b/packages/core/src/tools/create-custom-field.ts index 356e672..580e471 100644 --- a/packages/core/src/tools/create-custom-field.ts +++ b/packages/core/src/tools/create-custom-field.ts @@ -7,10 +7,12 @@ import type { } from "../types.js"; import { leadbay_create_custom_field as CREATE_CUSTOM_FIELD_DESCRIPTION } from "../tool-descriptions.generated.js"; +import { sanitizeConfigForType } from "./_custom-field-config.js"; interface CreateCustomFieldParams { name: string; type?: CustomCrmFieldKind; - config?: CustomCrmFieldConfig | null; + // Object preferred; a JSON string is also accepted (parsed by sanitize). + config?: CustomCrmFieldConfig | string | null; if_not_exists?: boolean; } @@ -39,9 +41,9 @@ export const createCustomField: Tool = { "Custom field type: TEXT, NUMBER, PRICE, DATE, DATETIME, or EXTERNAL_ID. Defaults to TEXT.", }, config: { - type: ["object", "null"], + type: ["object", "string", "null"], description: - "Type-specific config. EXTERNAL_ID requires {url_template:'https://.../{value}'}; PRICE requires {currency:'USD'}; DATE/DATETIME may set {format}.", + "Type-specific config, as an object (preferred) or a JSON string. EXTERNAL_ID requires {url_template:'https://.../{value}'}; PRICE requires {currency:'USD'}; DATE/DATETIME may set {format}.", }, if_not_exists: { type: "boolean", @@ -85,10 +87,14 @@ export const createCustomField: Tool = { } const type = params.type ?? "TEXT"; - const config = params.config ?? null; + // Narrow config to exactly the key(s) the type accepts (also parses a + // stringified config — LLMs often pass nested JSON as a string). The + // backend deserializer rejects extra keys with a 500 and drops the + // required key when config arrives as an unparsed string. + const config = sanitizeConfigForType(type, params.config ?? null); if (type === "EXTERNAL_ID") { - const urlTemplate = config?.url_template ?? config?.urlTemplate; + const urlTemplate = config?.url_template; if (!urlTemplate || !urlTemplate.includes("{value}")) { throw client.makeError( "CUSTOM_FIELD_EXTERNAL_ID_TEMPLATE_REQUIRED", diff --git a/packages/core/src/tools/delete-custom-field.ts b/packages/core/src/tools/delete-custom-field.ts new file mode 100644 index 0000000..c8ac1e8 --- /dev/null +++ b/packages/core/src/tools/delete-custom-field.ts @@ -0,0 +1,108 @@ +import type { LeadbayClient } from "../client.js"; +import type { CustomFieldDef, Tool } from "../types.js"; + +import { leadbay_delete_custom_field as DELETE_CUSTOM_FIELD_DESCRIPTION } from "../tool-descriptions.generated.js"; + +interface DeleteCustomFieldParams { + id: string; + confirm?: boolean; +} + +// Delete an org CRM custom field. Wire: DELETE /crm/custom_fields/{id} +// (returns 204; verified live). DESTRUCTIVE — removing the field drops its +// values from every lead and breaks any import mapping that targets +// CUSTOM.. Requires an explicit `confirm: true` so an accidental call +// can't wipe data. +export const deleteCustomField: Tool = { + name: "leadbay_delete_custom_field", + annotations: { + title: "Delete CRM custom field", + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: true, + }, + description: DELETE_CUSTOM_FIELD_DESCRIPTION, + write: true, + inputSchema: { + type: "object", + properties: { + id: { + type: "string", + description: + "Custom field id to delete (the numeric id from leadbay_list_mappable_fields, NOT the 'CUSTOM.' mapping value).", + }, + confirm: { + type: "boolean", + description: + "Must be true to actually delete. Without it the tool returns the field that WOULD be deleted and does nothing — a safety gate, because deletion drops the field's values from every lead.", + }, + }, + required: ["id"], + additionalProperties: false, + }, + outputSchema: { + type: "object", + properties: { + id: { type: "string" }, + name: { type: "string" }, + type: { type: "string" }, + deleted: { + type: "boolean", + description: "True when the field was deleted; false when confirm was not set.", + }, + hint: { + type: "string", + description: "Guidance — present when confirm was missing (re-call with confirm:true).", + }, + }, + required: ["id", "deleted"], + }, + execute: async (client: LeadbayClient, params: DeleteCustomFieldParams) => { + const id = String(params.id ?? "").trim(); + if (!id) { + throw client.makeError( + "CUSTOM_FIELD_ID_REQUIRED", + "id must be a non-empty string", + "Pass the custom field id from leadbay_list_mappable_fields (the numeric id, not 'CUSTOM.').", + "DELETE /crm/custom_fields/{id}" + ); + } + + // Resolve the field first so the response (and the confirm preview) names + // what is being removed, and so we 404 cleanly on a bad id. + const catalog = await client.request( + "GET", + "/crm/custom_fields" + ); + const current = (catalog ?? []).find((f) => String(f.id) === id); + if (!current) { + throw client.makeError( + "CUSTOM_FIELD_NOT_FOUND", + `no custom field with id ${id} on this org`, + "Call leadbay_list_mappable_fields to see the available custom fields and their ids.", + "DELETE /crm/custom_fields/{id}" + ); + } + + if (params.confirm !== true) { + return { + id, + name: current.name, + type: current.type, + deleted: false, + hint: `Deleting "${current.name}" removes its values from every lead and breaks any import mapping using CUSTOM.${id}. Re-call with confirm:true to proceed.`, + }; + } + + // 204 No Content on success. + await client.requestVoid("DELETE", `/crm/custom_fields/${id}`); + + return { + id, + name: current.name, + type: current.type, + deleted: true, + }; + }, +}; diff --git a/packages/core/src/tools/update-custom-field.ts b/packages/core/src/tools/update-custom-field.ts new file mode 100644 index 0000000..e6fcbdd --- /dev/null +++ b/packages/core/src/tools/update-custom-field.ts @@ -0,0 +1,162 @@ +import type { LeadbayClient } from "../client.js"; +import type { + CustomCrmFieldConfig, + CustomCrmFieldKind, + CustomFieldDef, + Tool, +} from "../types.js"; + +import { leadbay_update_custom_field as UPDATE_CUSTOM_FIELD_DESCRIPTION } from "../tool-descriptions.generated.js"; +import { sanitizeConfigForType } from "./_custom-field-config.js"; + +interface UpdateCustomFieldParams { + id: string; + name?: string; + type?: CustomCrmFieldKind; + // Object preferred; a JSON string is also accepted (some clients/models pass + // nested config stringified) — sanitizeConfigForType parses it. + config?: CustomCrmFieldConfig | string | null; +} + +// Update an existing org CRM custom field — rename it and/or change its type + +// config. Wire: POST /crm/custom_fields/{id} with {name, type, config?} +// (returns 204; verified live). The backend replaces the row, so name AND type +// are sent together — we resolve the current definition first and merge the +// caller's partial change over it, so a rename-only call keeps the type and a +// retype-only call keeps the name. +export const updateCustomField: Tool = { + name: "leadbay_update_custom_field", + annotations: { + title: "Update CRM custom field", + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + description: UPDATE_CUSTOM_FIELD_DESCRIPTION, + write: true, + inputSchema: { + type: "object", + properties: { + id: { + type: "string", + description: + "Custom field id to update (the numeric id from leadbay_list_mappable_fields, NOT the 'CUSTOM.' mapping value).", + }, + name: { + type: "string", + description: "New user-visible name. Omit to keep the current name.", + }, + type: { + type: "string", + description: + "New type: TEXT, NUMBER, PRICE, DATE, DATETIME, or EXTERNAL_ID. Omit to keep the current type.", + }, + config: { + type: ["object", "string", "null"], + description: + "New type-specific config, as an object (preferred) or a JSON string. EXTERNAL_ID requires {url_template:'https://.../{value}'}; PRICE requires {currency:'USD'}; DATE/DATETIME may set {format}. Omit to keep current config.", + }, + }, + required: ["id"], + additionalProperties: false, + }, + outputSchema: { + type: "object", + properties: { + id: { type: "string" }, + name: { type: "string" }, + type: { type: "string" }, + config: { type: ["object", "null"] }, + mapping_value: { + type: "string", + description: "Wire mapping value for import mappings, e.g. CUSTOM.123.", + }, + }, + required: ["id", "name", "type", "mapping_value"], + }, + execute: async (client: LeadbayClient, params: UpdateCustomFieldParams) => { + const id = String(params.id ?? "").trim(); + if (!id) { + throw client.makeError( + "CUSTOM_FIELD_ID_REQUIRED", + "id must be a non-empty string", + "Pass the custom field id from leadbay_list_mappable_fields (the numeric id, not 'CUSTOM.').", + "POST /crm/custom_fields/{id}" + ); + } + + if (params.name === undefined && params.type === undefined && params.config === undefined) { + throw client.makeError( + "CUSTOM_FIELD_NO_CHANGE", + "no field to update — pass at least one of name, type, config", + "Provide a new name and/or type (and config when the type needs it).", + "POST /crm/custom_fields/{id}" + ); + } + + // Resolve the current definition so a partial update preserves the rest. + const catalog = await client.request( + "GET", + "/crm/custom_fields" + ); + const current = (catalog ?? []).find((f) => String(f.id) === id); + if (!current) { + throw client.makeError( + "CUSTOM_FIELD_NOT_FOUND", + `no custom field with id ${id} on this org`, + "Call leadbay_list_mappable_fields to see the available custom fields and their ids.", + "POST /crm/custom_fields/{id}" + ); + } + + const name = params.name !== undefined ? params.name.trim() : current.name; + if (!name) { + throw client.makeError( + "CUSTOM_FIELD_NAME_REQUIRED", + "name must be a non-empty string", + "Pass a user-visible custom field name, or omit name to keep the current one.", + "POST /crm/custom_fields/{id}" + ); + } + const type = params.type !== undefined ? params.type : current.type; + const rawConfig = + params.config !== undefined ? params.config : (current.config ?? null); + + // Narrow config to exactly the key(s) the target type accepts (also parses + // a stringified config — LLMs often pass nested JSON as a string). The + // backend deserializer is strict: extra keys (e.g. a stale `format` left + // from the previous type) cause a 500, and a string config drops the + // required key. Critical on a type CHANGE, where current.config may carry + // keys the new type rejects. + const config = sanitizeConfigForType(type, rawConfig); + + if (type === "EXTERNAL_ID") { + const urlTemplate = config?.url_template; + if (!urlTemplate || !urlTemplate.includes("{value}")) { + throw client.makeError( + "CUSTOM_FIELD_EXTERNAL_ID_TEMPLATE_REQUIRED", + "EXTERNAL_ID custom fields require config.url_template containing {value}", + "Use a URL template like https://app.hubspot.com/contacts//record/0-1/{value}.", + "POST /crm/custom_fields/{id}" + ); + } + } + + const body = { + name, + type, + ...(config ? { config } : {}), + }; + // 204 No Content on success — no response body to parse. + await client.requestVoid("POST", `/crm/custom_fields/${id}`, body); + + return { + id, + name, + type, + config: config ?? null, + mapping_value: `CUSTOM.${id}`, + }; + }, +}; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index e4ade59..0f19ea9 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -125,6 +125,10 @@ export interface LeadPayload { disliked: boolean; new?: boolean; exported?: boolean; + // CRM custom-field values stored on this lead. Self-describing entries + // (see LeadCustomFieldEntry). Empty array when the org has no custom fields + // or none are set on this lead. Surfaced by leadbay_get_lead_custom_fields. + custom_fields?: LeadCustomFieldEntry[]; tags: LeadTag[]; phone_numbers?: string[]; keywords?: Array<{ keyword: string; score: number }>; @@ -561,6 +565,16 @@ export interface CustomFieldDef { config?: CustomCrmFieldConfig | null; } +// A custom-field VALUE as it appears on a lead detail/list payload +// (`GET /lenses/{lensId}/leads/{leadId}` → `custom_fields[]`). The entry is +// self-describing: it embeds the full field definition under `field`, so +// reading a lead's custom fields needs NO join against /crm/custom_fields. +// `value` is the stored cell text (null when set-but-empty). Verified live. +export interface LeadCustomFieldEntry { + field: CustomFieldDef; + value: string | null; +} + export interface PreProcessingStatePayloadV15 { finished: boolean; error?: string | null; diff --git a/packages/core/test/unit/composite/get-lead-custom-fields.test.ts b/packages/core/test/unit/composite/get-lead-custom-fields.test.ts new file mode 100644 index 0000000..d311323 --- /dev/null +++ b/packages/core/test/unit/composite/get-lead-custom-fields.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { mockHttp, resetHttpMock, getHttpRequests, httpsMockFactory } from "../../harness.js"; +vi.mock("node:https", () => httpsMockFactory()); + +import { LeadbayClient } from "../../../src/client.js"; +import { getLeadCustomFields } from "../../../src/composite/get-lead-custom-fields.js"; + +const BASE = "https://api-us.leadbay.app"; +const LEAD = "lead-7"; +const LENS = 42; +const newClient = () => new LeadbayClient(BASE, "u.test-token", "us"); + +beforeEach(() => resetHttpMock()); + +// lensId auto-resolves via /users/me.last_requested_lens (no /lenses scan). +function mockMe() { + return { + method: "GET" as const, + path: "/1.5/users/me", + status: 200, + body: { id: "u-1", organization: { id: "org-1", name: "Acme" }, last_requested_lens: LENS }, + }; +} + +const SEEN = { method: "POST" as const, path: "/1.5/interactions", status: 200, body: {} }; + +// A lead detail with self-describing custom_fields entries (verified live shape). +function mockLead(customFields: unknown[]) { + return { + method: "GET" as const, + path: new RegExp(`/1\\.5/lenses/${LENS}/leads/${LEAD}$`), + status: 200, + body: { + id: LEAD, + name: "Acme", + score: 80, + ai_agent_lead_score: null, + location: null, + description: null, + size: null, + website: "acme.com", + tags: [], + liked: false, + disliked: false, + contacts_count: 0, + org_contacts_count: 0, + custom_fields: customFields, + }, + }; +} + +describe("leadbay_get_lead_custom_fields", () => { + it("happy path — flattens self-describing entries to {id,name,type,value}, no catalog fetch", async () => { + mockHttp([ + mockMe(), + SEEN, + mockLead([ + { field: { id: "12", name: "Account Tier", type: "TEXT" }, value: "Gold" }, + { field: { id: "13", name: "ARR", type: "PRICE", config: { currency: "USD" } }, value: "50000" }, + ]), + ]); + + const res: any = await getLeadCustomFields.execute(newClient(), { leadId: LEAD }); + + expect(res.lead_id).toBe(LEAD); + expect(res.count).toBe(2); + expect(res.custom_fields).toEqual([ + { id: "12", name: "Account Tier", type: "TEXT", value: "Gold" }, + { id: "13", name: "ARR", type: "PRICE", value: "50000" }, + ]); + expect(res.hint).toBeUndefined(); + // No /crm/custom_fields fetch on the happy (self-describing) path. + expect(getHttpRequests().some((r) => /\/crm\/custom_fields/.test(r.path))).toBe(false); + }); + + it("empty — no values returns [] plus the empty-state hint", async () => { + mockHttp([mockMe(), SEEN, mockLead([])]); + const res: any = await getLeadCustomFields.execute(newClient(), { leadId: LEAD }); + + expect(res.custom_fields).toEqual([]); + expect(res.count).toBe(0); + expect(res.hint).toMatch(/no custom-field values/i); + }); + + it("fires LEAD_SEEN/LEAD_CLICKED on read", async () => { + mockHttp([mockMe(), SEEN, mockLead([])]); + await getLeadCustomFields.execute(newClient(), { leadId: LEAD }); + // The interaction POST is fire-and-forget — let the microtask settle. + await new Promise((resolve) => setTimeout(resolve, 10)); + + const interactions = getHttpRequests().filter((r) => /\/interactions$/.test(r.path) && r.method === "POST"); + expect(interactions).toHaveLength(1); + const events: Array<{ type: string }> = JSON.parse(interactions[0].body ?? "[]"); + const types = events.map((e) => e.type); + expect(types).toContain("LEAD_SEEN"); + expect(types).toContain("LEAD_CLICKED"); + }); + + it("explicit lensId bypasses lens resolution (lead fetched directly under the given lens)", async () => { + // /me is mocked because withAgentMemoryMeta resolves it for the memory + // summary — but lens resolution itself must NOT need it when lensId is given. + mockHttp([mockMe(), SEEN, mockLead([{ field: { id: "12", name: "Tier", type: "TEXT" }, value: "A" }])]); + const res: any = await getLeadCustomFields.execute(newClient(), { leadId: LEAD, lensId: LENS }); + + expect(res.count).toBe(1); + // The lead is fetched under the supplied lens id. + expect(getHttpRequests().some((r) => new RegExp(`/lenses/${LENS}/leads/${LEAD}`).test(r.path))).toBe(true); + }); + + it("degraded — entry without embedded field, catalog fetch fails → null name + degradation hint", async () => { + mockHttp([ + mockMe(), + SEEN, + mockLead([{ id: "99", value: "orphan" }]), + { method: "GET" as const, path: new RegExp(`/1\\.5/crm/custom_fields`), status: 500, body: { error: "boom" } }, + ]); + + const res: any = await getLeadCustomFields.execute(newClient(), { leadId: LEAD }); + + expect(res.custom_fields).toHaveLength(1); + expect(res.custom_fields[0]).toMatchObject({ id: "99", name: null, value: "orphan" }); + expect(res.hint).toMatch(/could not be named/i); + }); +}); diff --git a/packages/core/test/unit/composite/get-qualification-methods-error.test.ts b/packages/core/test/unit/composite/get-qualification-methods-error.test.ts new file mode 100644 index 0000000..9681e8b --- /dev/null +++ b/packages/core/test/unit/composite/get-qualification-methods-error.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { mockHttp, resetHttpMock, httpsMockFactory } from "../../harness.js"; +vi.mock("node:https", () => httpsMockFactory()); + +import { LeadbayClient } from "../../../src/client.js"; +import { getQualificationMethods } from "../../../src/composite/get-qualification-methods.js"; + +const BASE = "https://api-us.leadbay.app"; +const ORG = "org-1"; +const newClient = () => new LeadbayClient(BASE, "u.test-token", "us"); + +beforeEach(() => resetHttpMock()); + +const me = () => ({ + method: "GET" as const, + path: "/1.5/users/me", + status: 200, + body: { id: "u", organization: { id: ORG, name: "Acme" } }, +}); + +// Regression: a transient failure on the questions endpoint must SURFACE as an +// error, never be masked as "no questions configured" (which could lead a +// caller to overwrite an org's real questions). The tool fetches the endpoint +// directly rather than via resolveTasteProfile's Promise.allSettled (which +// substitutes [] on rejection). +describe("leadbay_get_qualification_methods — fetch failure surfaces", () => { + it("ai_agent_questions 500 → throws, does NOT return empty", async () => { + mockHttp([ + me(), + { method: "GET", path: new RegExp(`/1\\.5/organizations/${ORG}/ai_agent_questions`), status: 500, body: { error: "boom" } }, + ]); + await expect(getQualificationMethods.execute(newClient(), {})).rejects.toThrow(); + }); + + it("ai_agent_questions 401 → throws (auth failure not shown as empty)", async () => { + mockHttp([ + me(), + { method: "GET", path: new RegExp(`/1\\.5/organizations/${ORG}/ai_agent_questions`), status: 401, body: {} }, + ]); + await expect(getQualificationMethods.execute(newClient(), {})).rejects.toThrow(); + }); + + it("genuine empty (200 + []) → returns empty with the no-questions hint", async () => { + mockHttp([ + me(), + { method: "GET", path: new RegExp(`/1\\.5/organizations/${ORG}/ai_agent_questions`), status: 200, body: [] }, + ]); + const res: any = await getQualificationMethods.execute(newClient(), {}); + expect(res.count).toBe(0); + expect(res.hint).toMatch(/No qualification questions/i); + }); +}); diff --git a/packages/core/test/unit/composite/get-qualification-methods.test.ts b/packages/core/test/unit/composite/get-qualification-methods.test.ts new file mode 100644 index 0000000..075b301 --- /dev/null +++ b/packages/core/test/unit/composite/get-qualification-methods.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { mockHttp, resetHttpMock, getHttpRequests, httpsMockFactory } from "../../harness.js"; +vi.mock("node:https", () => httpsMockFactory()); + +import { LeadbayClient } from "../../../src/client.js"; +import { getQualificationMethods } from "../../../src/composite/get-qualification-methods.js"; + +const BASE = "https://api-us.leadbay.app"; +const ORG = "org-1"; +const newClient = () => new LeadbayClient(BASE, "u.test-token", "us"); + +beforeEach(() => resetHttpMock()); + +// resolveTasteProfile resolves the org id from /users/me, then fans out the +// three taste endpoints. resolveMe is also called for the is_admin flag (the +// /me read is cached, so it's hit once). +function mockMe(admin: boolean) { + return { + method: "GET" as const, + path: "/1.5/users/me", + status: 200, + body: { id: "u-1", email: "rep@acme.com", admin, organization: { id: ORG, name: "Acme" } }, + }; +} + +const QUESTIONS = [ + { question: "Does the company run install crews?", created_at: "2026-05-30T00:00:00Z", lang: "en" }, + { question: "Does the company spec modular flooring?", created_at: "2026-05-30T00:00:01Z", lang: "en" }, +]; + +function mockTaste(questions: unknown[]) { + return [ + { method: "GET" as const, path: new RegExp(`/1\\.5/organizations/${ORG}/ideal_buyer_profile`), status: 200, body: { summary: "IBP", key_characteristics: [], anti_patterns: [] } }, + { method: "GET" as const, path: new RegExp(`/1\\.5/organizations/${ORG}/purchase_intent_tags`), status: 200, body: [{ tag: "expanding", display_name: "Expanding" }] }, + { method: "GET" as const, path: new RegExp(`/1\\.5/organizations/${ORG}/ai_agent_questions`), status: 200, body: questions }, + ]; +} + +describe("leadbay_get_qualification_methods", () => { + it("happy path — returns only the questions with created_at + lang, no IBP/tags leak", async () => { + mockHttp([mockMe(false), ...mockTaste(QUESTIONS)]); + const res: any = await getQualificationMethods.execute(newClient(), {}); + + expect(res.qualification_questions).toHaveLength(2); + expect(res.qualification_questions[0]).toEqual({ + question: "Does the company run install crews?", + created_at: "2026-05-30T00:00:00Z", + lang: "en", + }); + expect(res.count).toBe(2); + expect(res.is_admin).toBe(false); + // The broader taste-profile fields must NOT be surfaced by this focused tool. + expect(res.ideal_buyer_profile).toBeUndefined(); + expect(res.purchase_intent_tags).toBeUndefined(); + // No admin hint for a non-admin with questions present. + expect(res.hint).toBeUndefined(); + }); + + it("admin user — surfaces is_admin + points at the modify tool", async () => { + mockHttp([mockMe(true), ...mockTaste(QUESTIONS)]); + const res: any = await getQualificationMethods.execute(newClient(), {}); + + expect(res.is_admin).toBe(true); + expect(res.hint).toMatch(/leadbay_set_qualification_methods/); + }); + + it("empty catalog — empty array + empty-state hint", async () => { + mockHttp([mockMe(false), ...mockTaste([])]); + const res: any = await getQualificationMethods.execute(newClient(), {}); + + expect(res.qualification_questions).toEqual([]); + expect(res.count).toBe(0); + expect(res.hint).toMatch(/No qualification questions/i); + }); + + it("does not POST anything (pure read)", async () => { + mockHttp([mockMe(false), ...mockTaste(QUESTIONS)]); + await getQualificationMethods.execute(newClient(), {}); + const writes = getHttpRequests().filter((r) => r.method !== "GET"); + expect(writes).toHaveLength(0); + }); +}); diff --git a/packages/core/test/unit/composite/set-qualification-methods-swap-confirm.test.ts b/packages/core/test/unit/composite/set-qualification-methods-swap-confirm.test.ts new file mode 100644 index 0000000..85d7e44 --- /dev/null +++ b/packages/core/test/unit/composite/set-qualification-methods-swap-confirm.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { mockHttp, resetHttpMock, getHttpRequests, httpsMockFactory } from "../../harness.js"; +vi.mock("node:https", () => httpsMockFactory()); + +import { LeadbayClient } from "../../../src/client.js"; +import { setQualificationMethods } from "../../../src/composite/set-qualification-methods.js"; + +const BASE = "https://api-us.leadbay.app"; +const ORG = "org-1"; +const newClient = () => new LeadbayClient(BASE, "u.test-token", "us"); + +beforeEach(() => resetHttpMock()); + +const me = () => ({ + method: "GET" as const, + path: "/1.5/users/me", + status: 200, + body: { id: "u", organization: { id: ORG, name: "Acme" } }, +}); +const Q1 = "Q one?"; +const Q2 = "Q two?"; +const Q3 = "Q three?"; +const current = (qs: string[]) => ({ + method: "GET" as const, + path: new RegExp(`/1\\.5/organizations/${ORG}/ai_agent_questions`), + status: 200, + body: qs.map((q) => ({ question: q, created_at: "2026-01-01T00:00:00Z", lang: "en" })), +}); +const postOrg = () => ({ method: "POST" as const, path: new RegExp(`/1\\.5/organizations/${ORG}$`), status: 204, body: null }); +const didPost = () => getHttpRequests().some((r) => r.method === "POST" && new RegExp(`/organizations/${ORG}$`).test(r.path)); + +// Regression: a remove+add (or `set`) that keeps the COUNT the same still drops +// a scoring question — must require confirm. The old guard only checked +// next.length < previousCount, so a same-count swap bypassed it. +describe("leadbay_set_qualification_methods — same-count swap needs confirm", () => { + it("remove Q1 + add Q3 (2→2, but Q1 dropped) without confirm — previews, does NOT post", async () => { + mockHttp([me(), current([Q1, Q2])]); + const res: any = await setQualificationMethods.execute(newClient(), { remove: [Q1], add: [Q3] }); + expect(res.changed).toBe(false); + expect(res.hint).toMatch(/confirm:true/); + expect(res.hint).toContain(Q1); + expect(didPost()).toBe(false); + }); + + it("same swap WITH confirm — posts the new list", async () => { + mockHttp([me(), current([Q1, Q2]), postOrg()]); + const res: any = await setQualificationMethods.execute(newClient(), { remove: [Q1], add: [Q3], confirm: true }); + expect(res.changed).toBe(true); + expect(res.qualification_questions.map((q: any) => q.question)).toEqual([Q2, Q3]); + expect(didPost()).toBe(true); + }); + + it("set replacing one question (same count, one dropped) without confirm — previews only", async () => { + mockHttp([me(), current([Q1, Q2])]); + const res: any = await setQualificationMethods.execute(newClient(), { questions: [Q1, Q3] }); + expect(res.changed).toBe(false); + expect(res.hint).toContain(Q2); // Q2 is the dropped one + expect(didPost()).toBe(false); + }); + + it("pure add (no removal) still needs NO confirm", async () => { + mockHttp([me(), current([Q1]), postOrg()]); + const res: any = await setQualificationMethods.execute(newClient(), { add: [Q2] }); + expect(res.changed).toBe(true); + expect(didPost()).toBe(true); + }); +}); diff --git a/packages/core/test/unit/composite/set-qualification-methods.test.ts b/packages/core/test/unit/composite/set-qualification-methods.test.ts new file mode 100644 index 0000000..faaac4f --- /dev/null +++ b/packages/core/test/unit/composite/set-qualification-methods.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { mockHttp, resetHttpMock, getHttpRequests, httpsMockFactory } from "../../harness.js"; +vi.mock("node:https", () => httpsMockFactory()); + +import { LeadbayClient } from "../../../src/client.js"; +import { setQualificationMethods } from "../../../src/composite/set-qualification-methods.js"; + +const BASE = "https://api-us.leadbay.app"; +const ORG = "org-1"; +const newClient = () => new LeadbayClient(BASE, "u.test-token", "us"); + +beforeEach(() => resetHttpMock()); + +const me = () => ({ + method: "GET" as const, + path: "/1.5/users/me", + status: 200, + body: { id: "u", organization: { id: ORG, name: "Acme" } }, +}); + +const Q1 = "Does the company run install crews?"; +const Q2 = "Does the company spec modular flooring?"; + +const currentQuestions = (qs: string[]) => ({ + method: "GET" as const, + path: new RegExp(`/1\\.5/organizations/${ORG}/ai_agent_questions`), + status: 200, + body: qs.map((q) => ({ question: q, created_at: "2026-05-30T00:00:00Z", lang: "en" })), +}); + +const postOrg = () => ({ + method: "POST" as const, + path: new RegExp(`/1\\.5/organizations/${ORG}$`), + status: 204, + body: null, +}); + +const postBody = () => { + const p = getHttpRequests().find((r) => r.method === "POST" && new RegExp(`/organizations/${ORG}$`).test(r.path)); + return p ? JSON.parse(p.body ?? "{}") : null; +}; + +describe("leadbay_set_qualification_methods", () => { + it("add — appends and posts the full ai_agent_lead_questions array", async () => { + mockHttp([me(), currentQuestions([Q1]), postOrg()]); + + const res: any = await setQualificationMethods.execute(newClient(), { add: [Q2] }); + + expect(res.changed).toBe(true); + expect(res.count).toBe(2); + expect(res.previous_count).toBe(1); + expect(res.qualification_questions.map((q: any) => q.question)).toEqual([Q1, Q2]); + // Full-replace wire shape. + expect(postBody()).toEqual({ ai_agent_lead_questions: [Q1, Q2] }); + }); + + it("add duplicate — no-op, does not POST", async () => { + mockHttp([me(), currentQuestions([Q1])]); + + const res: any = await setQualificationMethods.execute(newClient(), { add: [Q1] }); + + expect(res.changed).toBe(false); + expect(res.hint).toMatch(/No change/i); + expect(getHttpRequests().some((r) => r.method === "POST")).toBe(false); + }); + + it("remove without confirm — previews, does NOT post", async () => { + mockHttp([me(), currentQuestions([Q1, Q2])]); + + const res: any = await setQualificationMethods.execute(newClient(), { remove: [Q2] }); + + expect(res.changed).toBe(false); + expect(res.hint).toMatch(/confirm:true/); + expect(res.hint).toContain(Q2); + expect(getHttpRequests().some((r) => r.method === "POST")).toBe(false); + }); + + it("remove with confirm — posts the shrunk list", async () => { + mockHttp([me(), currentQuestions([Q1, Q2]), postOrg()]); + + const res: any = await setQualificationMethods.execute(newClient(), { remove: [Q2], confirm: true }); + + expect(res.changed).toBe(true); + expect(res.count).toBe(1); + expect(postBody()).toEqual({ ai_agent_lead_questions: [Q1] }); + }); + + it("full replace (set) with MORE questions needs no confirm", async () => { + mockHttp([me(), currentQuestions([Q1]), postOrg()]); + + const res: any = await setQualificationMethods.execute(newClient(), { questions: [Q1, Q2] }); + + expect(res.changed).toBe(true); + expect(res.count).toBe(2); + expect(postBody()).toEqual({ ai_agent_lead_questions: [Q1, Q2] }); + }); + + it("questions + add together — rejected (mutually exclusive)", async () => { + mockHttp([]); + await expect( + setQualificationMethods.execute(newClient(), { questions: [Q1], add: [Q2] }) + ).rejects.toThrow(); + expect(getHttpRequests()).toHaveLength(0); + }); + + it("no args — rejected", async () => { + mockHttp([]); + await expect(setQualificationMethods.execute(newClient(), {})).rejects.toThrow(); + expect(getHttpRequests()).toHaveLength(0); + }); + + it("exceeding the 5-question cap — rejects with limit hint, no POST", async () => { + mockHttp([me(), currentQuestions([Q1, Q2, "q3", "q4", "q5"])]); + await expect( + setQualificationMethods.execute(newClient(), { add: ["q6"] }) + ).rejects.toThrow(/max 5/i); + expect(getHttpRequests().some((r) => r.method === "POST")).toBe(false); + }); +}); diff --git a/packages/core/test/unit/tools/custom-field-config.test.ts b/packages/core/test/unit/tools/custom-field-config.test.ts new file mode 100644 index 0000000..9c921b5 --- /dev/null +++ b/packages/core/test/unit/tools/custom-field-config.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from "vitest"; +import { sanitizeConfigForType } from "../../../src/tools/_custom-field-config.js"; + +// Guards the fix for the live "JSON deserialization error" (500) on custom-field +// create/update: the backend's per-type config models are strict, so any extra +// key must be stripped before sending. Especially on a type CHANGE where the +// previous config carries keys the new type rejects. +describe("sanitizeConfigForType", () => { + it("PRICE — keeps only currency, drops extra keys", () => { + expect( + sanitizeConfigForType("PRICE", { currency: "EUR", format: null, url_template: "x" }) + ).toEqual({ currency: "EUR" }); + }); + + it("PRICE — no currency → null (caller omits config; backend 400s with a clear message)", () => { + expect(sanitizeConfigForType("PRICE", { format: null })).toBeNull(); + }); + + it("DATE / DATETIME — keeps only format (nullable)", () => { + expect(sanitizeConfigForType("DATE", { format: "yyyy-MM-dd", currency: "EUR" })).toEqual({ + format: "yyyy-MM-dd", + }); + expect(sanitizeConfigForType("DATETIME", { format: null })).toEqual({ format: null }); + }); + + it("EXTERNAL_ID — normalizes url_template / urlTemplate to snake_case wire key only", () => { + expect( + sanitizeConfigForType("EXTERNAL_ID", { urlTemplate: "https://x/{value}", currency: "EUR" }) + ).toEqual({ url_template: "https://x/{value}" }); + expect( + sanitizeConfigForType("EXTERNAL_ID", { url_template: "https://y/{value}" }) + ).toEqual({ url_template: "https://y/{value}" }); + }); + + it("TEXT / NUMBER — never carry config", () => { + expect(sanitizeConfigForType("TEXT", { currency: "EUR" })).toBeNull(); + expect(sanitizeConfigForType("NUMBER", { format: "x" })).toBeNull(); + }); + + it("null / undefined config → null", () => { + expect(sanitizeConfigForType("PRICE", null)).toBeNull(); + expect(sanitizeConfigForType("PRICE", undefined)).toBeNull(); + }); + + it("unknown type → null (no config forwarded)", () => { + expect(sanitizeConfigForType("WEIRD" as any, { currency: "EUR" })).toBeNull(); + }); + + it("STRINGIFIED config — parses it (LLMs pass nested JSON as a string)", () => { + // The exact shape observed live: agent sent config as a JSON string with + // an extra `code` key. Must parse + narrow to {currency}. + expect( + sanitizeConfigForType("PRICE", '{"currency":"EUR","code":"EUR"}' as any) + ).toEqual({ currency: "EUR" }); + expect( + sanitizeConfigForType("EXTERNAL_ID", '{"url_template":"https://x/{value}"}' as any) + ).toEqual({ url_template: "https://x/{value}" }); + }); + + it("unparseable string → null", () => { + expect(sanitizeConfigForType("PRICE", "not json" as any)).toBeNull(); + }); +}); diff --git a/packages/core/test/unit/tools/delete-custom-field.test.ts b/packages/core/test/unit/tools/delete-custom-field.test.ts new file mode 100644 index 0000000..7cb09bd --- /dev/null +++ b/packages/core/test/unit/tools/delete-custom-field.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { mockHttp, resetHttpMock, getHttpRequests, httpsMockFactory } from "../../harness.js"; +vi.mock("node:https", () => httpsMockFactory()); + +import { LeadbayClient } from "../../../src/client.js"; +import { deleteCustomField } from "../../../src/tools/delete-custom-field.js"; + +const BASE = "https://api-us.leadbay.app"; +const newClient = () => new LeadbayClient(BASE, "u.test-token", "us"); + +beforeEach(() => resetHttpMock()); + +const catalog = (rows: unknown[]) => ({ + method: "GET" as const, + path: "/1.5/crm/custom_fields", + status: 200, + body: rows, +}); + +describe("leadbay_delete_custom_field", () => { + it("without confirm — previews, does NOT delete", async () => { + mockHttp([catalog([{ id: "12", name: "Legacy Source", type: "TEXT" }])]); + + const res: any = await deleteCustomField.execute(newClient(), { id: "12" }); + + expect(res).toMatchObject({ id: "12", name: "Legacy Source", deleted: false }); + expect(res.hint).toMatch(/confirm:true/); + // No DELETE fired. + expect(getHttpRequests().some((r) => r.method === "DELETE")).toBe(false); + }); + + it("with confirm — deletes and reports the removed field", async () => { + mockHttp([ + catalog([{ id: "12", name: "Legacy Source", type: "TEXT" }]), + { method: "DELETE", path: /\/1\.5\/crm\/custom_fields\/12$/, status: 204, body: null }, + ]); + + const res: any = await deleteCustomField.execute(newClient(), { id: "12", confirm: true }); + + expect(res).toMatchObject({ id: "12", name: "Legacy Source", type: "TEXT", deleted: true }); + expect(getHttpRequests().some((r) => r.method === "DELETE" && /\/crm\/custom_fields\/12$/.test(r.path))).toBe(true); + }); + + it("unknown id — NOT_FOUND, no DELETE", async () => { + mockHttp([catalog([{ id: "12", name: "Tier", type: "TEXT" }])]); + await expect(deleteCustomField.execute(newClient(), { id: "999", confirm: true })).rejects.toThrow(); + expect(getHttpRequests().some((r) => r.method === "DELETE")).toBe(false); + }); + + it("missing id — rejects before any HTTP", async () => { + mockHttp([]); + await expect(deleteCustomField.execute(newClient(), { id: "" })).rejects.toThrow(); + expect(getHttpRequests()).toHaveLength(0); + }); +}); diff --git a/packages/core/test/unit/tools/update-custom-field.test.ts b/packages/core/test/unit/tools/update-custom-field.test.ts new file mode 100644 index 0000000..0b6cd3d --- /dev/null +++ b/packages/core/test/unit/tools/update-custom-field.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { mockHttp, resetHttpMock, getHttpRequests, httpsMockFactory } from "../../harness.js"; +vi.mock("node:https", () => httpsMockFactory()); + +import { LeadbayClient } from "../../../src/client.js"; +import { updateCustomField } from "../../../src/tools/update-custom-field.js"; + +const BASE = "https://api-us.leadbay.app"; +const newClient = () => new LeadbayClient(BASE, "u.test-token", "us"); + +beforeEach(() => resetHttpMock()); + +const catalog = (rows: unknown[]) => ({ + method: "GET" as const, + path: "/1.5/crm/custom_fields", + status: 200, + body: rows, +}); + +describe("leadbay_update_custom_field", () => { + it("rename-only — preserves the existing type", async () => { + mockHttp([ + catalog([{ id: "12", name: "Tier", type: "TEXT" }]), + { method: "POST", path: /\/1\.5\/crm\/custom_fields\/12$/, status: 204, body: null }, + ]); + + const res: any = await updateCustomField.execute(newClient(), { id: "12", name: "Account Tier" }); + + expect(res).toMatchObject({ id: "12", name: "Account Tier", type: "TEXT", mapping_value: "CUSTOM.12" }); + // The POST body merges the rename over the current type. + const post = getHttpRequests().find((r) => r.method === "POST" && /\/crm\/custom_fields\/12$/.test(r.path)); + expect(JSON.parse(post!.body ?? "{}")).toMatchObject({ name: "Account Tier", type: "TEXT" }); + }); + + it("retype to PRICE — keeps name, sends config", async () => { + mockHttp([ + catalog([{ id: "13", name: "ARR", type: "NUMBER" }]), + { method: "POST", path: /\/1\.5\/crm\/custom_fields\/13$/, status: 204, body: null }, + ]); + + const res: any = await updateCustomField.execute(newClient(), { + id: "13", + type: "PRICE", + config: { currency: "USD" }, + }); + + expect(res).toMatchObject({ id: "13", name: "ARR", type: "PRICE" }); + expect(res.config).toEqual({ currency: "USD" }); + }); + + it("no change fields — rejects before any HTTP", async () => { + mockHttp([]); + await expect(updateCustomField.execute(newClient(), { id: "12" })).rejects.toThrow(); + expect(getHttpRequests()).toHaveLength(0); + }); + + it("unknown id — NOT_FOUND, no POST", async () => { + mockHttp([catalog([{ id: "12", name: "Tier", type: "TEXT" }])]); + await expect(updateCustomField.execute(newClient(), { id: "999", name: "X" })).rejects.toThrow(); + expect(getHttpRequests().some((r) => r.method === "POST")).toBe(false); + }); + + it("EXTERNAL_ID without url_template — rejects before POST", async () => { + mockHttp([catalog([{ id: "12", name: "Tier", type: "TEXT" }])]); + await expect( + updateCustomField.execute(newClient(), { id: "12", type: "EXTERNAL_ID" }) + ).rejects.toThrow(); + expect(getHttpRequests().some((r) => r.method === "POST")).toBe(false); + }); +}); diff --git a/packages/mcp/CHANGELOG.md b/packages/mcp/CHANGELOG.md index e68101f..5771f8a 100644 --- a/packages/mcp/CHANGELOG.md +++ b/packages/mcp/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog — @leadbay/mcp +## 0.23.1 — 2026-06-22 + +Retrieve + modify qualification methods and CRM custom fields over MCP (product#3768). + +- **`leadbay_get_qualification_methods`** (new, always-on read) — the org's AI-agent qualification questions (the criteria every lead is scored against), with the caller's `is_admin` flag. Fetches the questions endpoint directly, so a transient backend/auth failure surfaces as an error rather than a false "none configured". +- **`leadbay_set_qualification_methods`** (new write) — add / remove / replace the org's questions. Reads the current list and posts the full result; enforces the backend's 5-question cap; **any change that drops an existing question requires `confirm:true`** (gated on the actual removed set, so a same-count swap still confirms). +- **`leadbay_get_lead_custom_fields`** (new, always-on read) — the CRM custom-field VALUES stored on one lead (`{id, name, type, value}`), distinct from the definitions catalog in `leadbay_list_mappable_fields`. Fires `LEAD_SEEN`. +- **`leadbay_update_custom_field` / `leadbay_delete_custom_field`** (new writes) — rename/retype a field in place, or delete it (delete requires `confirm:true`). Config is sanitized per type (and a stringified config is parsed) so the backend's strict deserializer never rejects it; the input schema advertises `object | string | null`. +- Modify tools are admin-scoped server-side (every user is admin of their own org). Requires backend leadbay/backend#1906 so OAuth tokens are accepted on the org + custom-field routes. + ## 0.23.0 — 2026-06-21 Guided campaign builder. diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 22cf351..4c394b6 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@leadbay/mcp", - "version": "0.23.0", + "version": "0.23.1", "mcpName": "io.github.leadbay/leadbay-mcp", "description": "Model Context Protocol (MCP) server for Leadbay — AI lead discovery, qualification, and enrichment for Claude Desktop, Cursor, and Claude Code.", "type": "module", diff --git a/packages/mcp/server.json b/packages/mcp/server.json index a12007b..ee8bd74 100644 --- a/packages/mcp/server.json +++ b/packages/mcp/server.json @@ -3,7 +3,7 @@ "name": "io.github.leadbay/leadbay-mcp", "title": "Leadbay", "description": "AI lead discovery, qualification, and outreach prep on your Leadbay account.", - "version": "0.23.0", + "version": "0.23.1", "repository": { "url": "https://github.com/leadbay/leadclaw", "source": "github", @@ -15,7 +15,7 @@ "registryType": "npm", "registryBaseUrl": "https://registry.npmjs.org", "identifier": "@leadbay/mcp", - "version": "0.23.0", + "version": "0.23.1", "transport": { "type": "stdio" }, diff --git a/packages/mcp/src/oauth.ts b/packages/mcp/src/oauth.ts index b1f9738..be35ae7 100644 --- a/packages/mcp/src/oauth.ts +++ b/packages/mcp/src/oauth.ts @@ -22,7 +22,7 @@ import { createServer, IncomingMessage, ServerResponse } from "node:http"; import { request as httpsRequestRaw } from "node:https"; import { spawn } from "node:child_process"; import { AddressInfo } from "node:net"; -import { readdirSync } from "node:fs"; +import { readdirSync, existsSync } from "node:fs"; // Stable loopback port for the OAuth callback. The Leadbay backend requires the // authorize redirect_uri to EXACTLY match the registered one (no RFC 8252 @@ -567,7 +567,24 @@ export function browserLaunchEnv(debug?: (msg: string) => void): NodeJS.ProcessE const env = { ...process.env }; if (process.platform !== "linux") return env; - const runtimeDir = env.XDG_RUNTIME_DIR; + // Claude Desktop spawns the .dxt server with a sanitized env that often + // strips XDG_RUNTIME_DIR too — which breaks the WAYLAND_DISPLAY/DBUS + // reconstruction below (both live under that dir). Rebuild it from the uid: + // /run/user/ is the systemd-standard runtime dir on every modern Linux. + let runtimeDir = env.XDG_RUNTIME_DIR; + if (!runtimeDir || !existsSync(runtimeDir)) { + try { + const uid = process.getuid?.(); + const candidate = uid !== undefined ? `/run/user/${uid}` : undefined; + if (candidate && existsSync(candidate)) { + runtimeDir = candidate; + env.XDG_RUNTIME_DIR = candidate; + debug?.(`browserLaunchEnv: injected XDG_RUNTIME_DIR=${candidate}`); + } + } catch { + /* getuid unavailable — fall through */ + } + } if (!env.WAYLAND_DISPLAY && runtimeDir) { // Find a wayland-N socket in the runtime dir (wayland-0 is the default). @@ -582,6 +599,18 @@ export function browserLaunchEnv(debug?: (msg: string) => void): NodeJS.ProcessE } } + // Snap/Flatpak browsers (the common default on Ubuntu — firefox is a Snap) + // need the session bus to hand the URL to a running instance. Claude Desktop + // strips DBUS_SESSION_BUS_ADDRESS; reconstruct it from the standard socket + // at /bus so `xdg-open` doesn't silently no-op. + if (!env.DBUS_SESSION_BUS_ADDRESS && runtimeDir) { + const busPath = `${runtimeDir}/bus`; + if (existsSync(busPath)) { + env.DBUS_SESSION_BUS_ADDRESS = `unix:path=${busPath}`; + debug?.(`browserLaunchEnv: injected DBUS_SESSION_BUS_ADDRESS=unix:path=${busPath}`); + } + } + if (!env.DISPLAY) { // X11 sockets live at /tmp/.X11-unix/X; ":0" is the near-universal // default and works under XWayland too. Prefer the lowest-numbered. @@ -597,6 +626,34 @@ export function browserLaunchEnv(debug?: (msg: string) => void): NodeJS.ProcessE debug?.(`browserLaunchEnv: injected DISPLAY=${env.DISPLAY}`); } + // An X11/XWayland browser (Chrome/Brave/Electron-based) needs the X authority + // cookie to connect to the display — without it the X server rejects the + // client ("Authorization required, but no authorization protocol specified") + // and the browser exits/segfaults, so no tab opens even though xdg-open + // returned 0. Claude Desktop strips XAUTHORITY; under Wayland/Mutter the + // Xwayland cookie lives at /.mutter-Xwaylandauth.* — reconstruct + // from there, falling back to ~/.Xauthority. + if (!env.XAUTHORITY) { + try { + let xauth: string | undefined; + if (runtimeDir) { + const cookie = readdirSync(runtimeDir).find((f) => + /^\.mutter-Xwaylandauth\./.test(f) + ); + if (cookie) xauth = `${runtimeDir}/${cookie}`; + } + if (!xauth && env.HOME && existsSync(`${env.HOME}/.Xauthority`)) { + xauth = `${env.HOME}/.Xauthority`; + } + if (xauth) { + env.XAUTHORITY = xauth; + debug?.(`browserLaunchEnv: injected XAUTHORITY=${xauth}`); + } + } catch { + /* runtime dir unreadable — proceed without (Wayland-native apps still work) */ + } + } + return env; } diff --git a/packages/mcp/test/audit/routing-block.test.ts b/packages/mcp/test/audit/routing-block.test.ts index 9cc603a..9257977 100644 --- a/packages/mcp/test/audit/routing-block.test.ts +++ b/packages/mcp/test/audit/routing-block.test.ts @@ -56,6 +56,8 @@ const TOOLS_WITH_ROUTING = new Set([ "leadbay_new_lens", "leadbay_adjust_audience", "leadbay_refine_prompt", + "leadbay_get_qualification_methods", + "leadbay_get_lead_custom_fields", "leadbay_add_contact", "leadbay_remove_contact", "leadbay_pin_contact", diff --git a/packages/mcp/test/output-schema-conformance.test.ts b/packages/mcp/test/output-schema-conformance.test.ts index 7b5e049..26d8f39 100644 --- a/packages/mcp/test/output-schema-conformance.test.ts +++ b/packages/mcp/test/output-schema-conformance.test.ts @@ -161,6 +161,153 @@ interface ConformanceCase { } const CASES: ConformanceCase[] = [ + { + toolName: "leadbay_set_qualification_methods", + arguments: { add: ["Is the company hiring installers?"] }, + setupMocks: () => { + mockHttp([ + { + method: "GET", + path: "/1.5/users/me", + status: 200, + body: { id: "u", organization: { id: "org-1", name: "Test Co" } }, + }, + { + method: "GET", + path: /\/1\.5\/organizations\/org-1\/ai_agent_questions/, + status: 200, + body: [{ question: "Does the company run install crews?", created_at: "2026-05-30T00:00:00Z", lang: "en" }], + }, + { + method: "POST", + path: /\/1\.5\/organizations\/org-1$/, + status: 204, + body: null, + }, + ]); + }, + }, + { + toolName: "leadbay_update_custom_field", + arguments: { id: "12", name: "Account Tier" }, + setupMocks: () => { + mockHttp([ + { + method: "GET", + path: "/1.5/crm/custom_fields", + status: 200, + body: [{ id: "12", name: "Tier", type: "TEXT" }], + }, + { + method: "POST", + path: /\/1\.5\/crm\/custom_fields\/12$/, + status: 204, + body: null, + }, + ]); + }, + }, + { + toolName: "leadbay_delete_custom_field", + arguments: { id: "12", confirm: true }, + setupMocks: () => { + mockHttp([ + { + method: "GET", + path: "/1.5/crm/custom_fields", + status: 200, + body: [{ id: "12", name: "Legacy Source", type: "TEXT" }], + }, + { + method: "DELETE", + path: /\/1\.5\/crm\/custom_fields\/12$/, + status: 204, + body: null, + }, + ]); + }, + }, + { + toolName: "leadbay_get_qualification_methods", + arguments: {}, + setupMocks: () => { + mockHttp([ + { + method: "GET", + path: "/1.5/users/me", + status: 200, + body: { + id: "u", + email: "rep@example.com", + admin: false, + organization: { id: "org-1", name: "Test Co" }, + }, + }, + { + method: "GET", + path: /\/1\.5\/organizations\/org-1\/ideal_buyer_profile/, + status: 200, + body: { summary: "IBP", key_characteristics: [], anti_patterns: [] }, + }, + { + method: "GET", + path: /\/1\.5\/organizations\/org-1\/purchase_intent_tags/, + status: 200, + body: [], + }, + { + method: "GET", + path: /\/1\.5\/organizations\/org-1\/ai_agent_questions/, + status: 200, + body: [ + { question: "Does the company run install crews?", created_at: "2026-05-30T00:00:00Z", lang: "en" }, + ], + }, + ]); + }, + }, + { + toolName: "leadbay_get_lead_custom_fields", + arguments: { leadId: "lead-7" }, + setupMocks: () => { + mockHttp([ + { + method: "GET", + path: "/1.5/users/me", + status: 200, + body: { + id: "u", + organization: { id: "org-1", name: "Test Co" }, + last_requested_lens: 42, + }, + }, + { method: "POST", path: "/1.5/interactions", status: 200, body: {} }, + { + method: "GET", + path: /\/1\.5\/lenses\/42\/leads\/lead-7$/, + status: 200, + body: { + id: "lead-7", + name: "Acme", + score: 80, + ai_agent_lead_score: null, + location: null, + description: null, + size: null, + website: "acme.com", + tags: [], + liked: false, + disliked: false, + contacts_count: 0, + org_contacts_count: 0, + custom_fields: [ + { field: { id: "12", name: "Account Tier", type: "TEXT" }, value: "Gold" }, + ], + }, + }, + ]); + }, + }, { toolName: "leadbay_resolve_import_rows", arguments: { diff --git a/packages/mcp/test/unit/oauth-browser-env-wayland.test.ts b/packages/mcp/test/unit/oauth-browser-env-wayland.test.ts new file mode 100644 index 0000000..efcebc5 --- /dev/null +++ b/packages/mcp/test/unit/oauth-browser-env-wayland.test.ts @@ -0,0 +1,97 @@ +/** + * Regression test for OAuth-on-install browser launch on Wayland + Snap/Flatpak + * browsers (the common Ubuntu default). + * + * Root cause being guarded: Claude Desktop spawns the .dxt/.mcpb stdio server + * with a sanitized env that strips XDG_RUNTIME_DIR, WAYLAND_DISPLAY, and + * DBUS_SESSION_BUS_ADDRESS. Without them `xdg-open` spawns "successfully" but a + * Snap browser can't reach the compositor / session bus, so no tab opens and + * the OAuth page never appears. `browserLaunchEnv()` must reconstruct these + * from /run/user/ so the launch actually reaches the browser. + * + * Confirmed live on a Wayland + Snap-firefox machine: with the reconstructed + * env, the tab opens; without it, silent no-op. + * + * New file (existing oauth-browser-open.test.ts is left untouched). + */ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { existsSync, readdirSync } from "node:fs"; +import { browserLaunchEnv } from "../../src/oauth.js"; + +const isLinux = process.platform === "linux"; +const uid = process.getuid?.(); +const runtimeDir = uid !== undefined ? `/run/user/${uid}` : undefined; +const hasRuntimeDir = !!runtimeDir && existsSync(runtimeDir); + +describe("browserLaunchEnv — Wayland/DBus reconstruction", () => { + const saved = { + XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR, + WAYLAND_DISPLAY: process.env.WAYLAND_DISPLAY, + DBUS_SESSION_BUS_ADDRESS: process.env.DBUS_SESSION_BUS_ADDRESS, + DISPLAY: process.env.DISPLAY, + XAUTHORITY: process.env.XAUTHORITY, + }; + + beforeEach(() => { + // Simulate Claude Desktop's stripped child env. + delete process.env.XDG_RUNTIME_DIR; + delete process.env.WAYLAND_DISPLAY; + delete process.env.DBUS_SESSION_BUS_ADDRESS; + delete process.env.DISPLAY; + delete process.env.XAUTHORITY; + }); + + afterEach(() => { + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + }); + + it("non-linux: returns env untouched", () => { + if (isLinux) return; // only meaningful off-linux + const env = browserLaunchEnv(); + expect(env.XDG_RUNTIME_DIR).toBeUndefined(); + }); + + it("linux: always sets a DISPLAY fallback even with nothing in env", () => { + if (!isLinux) return; + const env = browserLaunchEnv(); + // DISPLAY is reconstructed unconditionally (":0" worst case). + expect(env.DISPLAY).toBeTruthy(); + }); + + it("linux + real runtime dir: reconstructs XDG_RUNTIME_DIR, and DBUS when the bus socket exists", () => { + if (!isLinux || !hasRuntimeDir) return; // skip on headless CI without /run/user/ + const env = browserLaunchEnv(); + expect(env.XDG_RUNTIME_DIR).toBe(runtimeDir); + // The bus socket is the standard Snap/Flatpak handoff path; when present it + // must be wired so xdg-open can reach a running browser instance. + if (existsSync(`${runtimeDir}/bus`)) { + expect(env.DBUS_SESSION_BUS_ADDRESS).toBe(`unix:path=${runtimeDir}/bus`); + } + // X11/XWayland browsers need the X authority cookie or they can't connect to + // the display. Under Mutter/Wayland it lives at /.mutter-Xwaylandauth.* + const cookie = readdirSync(runtimeDir!).find((f) => /^\.mutter-Xwaylandauth\./.test(f)); + if (cookie) { + expect(env.XAUTHORITY).toBe(`${runtimeDir}/${cookie}`); + } + }); + + it("does not override values already present in env (when the runtime dir exists)", () => { + if (!isLinux || !hasRuntimeDir) return; + // A real, existing runtime dir is kept as-is (the override only kicks in + // when XDG_RUNTIME_DIR is missing or points at a non-existent path). + process.env.XDG_RUNTIME_DIR = runtimeDir; + process.env.DBUS_SESSION_BUS_ADDRESS = "unix:path=/already/set"; + process.env.WAYLAND_DISPLAY = "wayland-9"; + process.env.DISPLAY = ":7"; + process.env.XAUTHORITY = "/already/.Xauthority"; + const env = browserLaunchEnv(); + expect(env.XDG_RUNTIME_DIR).toBe(runtimeDir); + expect(env.DBUS_SESSION_BUS_ADDRESS).toBe("unix:path=/already/set"); + expect(env.WAYLAND_DISPLAY).toBe("wayland-9"); + expect(env.DISPLAY).toBe(":7"); + expect(env.XAUTHORITY).toBe("/already/.Xauthority"); + }); +}); diff --git a/packages/promptforge/tool-descriptions/composite/delete-custom-field.md.tmpl b/packages/promptforge/tool-descriptions/composite/delete-custom-field.md.tmpl new file mode 100644 index 0000000..a04c498 --- /dev/null +++ b/packages/promptforge/tool-descriptions/composite/delete-custom-field.md.tmpl @@ -0,0 +1,29 @@ +--- +name: leadbay_delete_custom_field +kind: tool-description +short_description: | + Delete an org CRM custom field. DESTRUCTIVE — drops the field's values from + every lead. Requires confirm:true. Pass the field id from + leadbay_list_mappable_fields. +annotations: + readOnlyHint: false + destructiveHint: true + idempotentHint: false + openWorldHint: true +--- +Delete an org-level CRM custom field. Use when the user explicitly wants to remove a custom field from their account — e.g. "delete the old 'Legacy Source' field". + +**This is destructive.** Removing the field drops its stored values from every lead and breaks any import mapping that targets `CUSTOM.`. For that reason the tool has a safety gate: + +- Call with `id` only → the tool returns the field that WOULD be deleted (`{id, name, type, deleted:false, hint}`) and changes nothing. Surface the hint to the user and ask them to confirm. +- Call with `id` + `confirm:true` → the field is deleted (`{id, name, type, deleted:true}`). + +`id` is the numeric custom-field id from `leadbay_list_mappable_fields` — NOT the `CUSTOM.` mapping value. + +{{include:headers/tool-when-to-use}} the user explicitly asks to delete/remove a custom field — and only fire with confirm:true after they confirm. + +{{include:headers/tool-when-not-to-use}} to rename or retype a field (use leadbay_update_custom_field) or to create one (use leadbay_create_custom_field). + +### RENDERING + +Before deleting (no confirm), render the `hint` and ask the user to confirm — do NOT auto-confirm on the user's behalf. After a confirmed delete, acknowledge in one line: **"Deleted custom field #12 'Legacy Source'."** diff --git a/packages/promptforge/tool-descriptions/composite/get-lead-custom-fields.md.tmpl b/packages/promptforge/tool-descriptions/composite/get-lead-custom-fields.md.tmpl new file mode 100644 index 0000000..ef0db8c --- /dev/null +++ b/packages/promptforge/tool-descriptions/composite/get-lead-custom-fields.md.tmpl @@ -0,0 +1,74 @@ +--- +name: leadbay_get_lead_custom_fields +kind: tool-description +short_description: | + Retrieve the CRM custom-field VALUES stored on one lead. Use when the user + asks what custom fields a specific company/lead has, or for the value of a + named custom field on a lead. Don't use it for the custom-field DEFINITIONS / + catalog (that's leadbay_list_mappable_fields) or for a lead's full research + dossier (that's leadbay_research_lead_by_id). +routing: + triggers: + - "what custom fields are on this lead" + - "show the CRM custom field values for " + - "what's the on this lead" + - "get lead 's custom fields" + - "does this lead have any custom field values" + anti_triggers: + - phrase: "what custom fields exist on my account" + route_to: leadbay_list_mappable_fields + - phrase: "give me the full research dossier on this lead" + route_to: leadbay_research_lead_by_id + prefer_when: "user wants the custom-field VALUES on ONE lead; pass `leadId`" + examples: + positive: + - "What custom field values are stored on this lead?" + - "Show me the CRM custom fields for that company." + negative: + - "What custom fields are defined on my account?" + - "Give me the full research breakdown on Acme Corp." +rendering_hint: | + 3-column markdown table: Field | Type | Value, one row per entry. When + `custom_fields` is empty, render the `hint` sentence instead of an empty + table. +annotations: + readOnlyHint: true + destructiveHint: false + idempotentHint: true + openWorldHint: true +--- +Retrieve the CRM custom-field **values** stored on a single lead — the actual +data (`value`) per custom field, not the field definitions. + +This is distinct from **leadbay_list_mappable_fields**, which returns the org's +custom-field *catalog* (the definitions: id/name/type, used for import +mapping). This tool answers "what does *this* lead hold for each custom field". + +Params: `leadId` (required UUID); `lensId` (optional escape hatch — normally +omit; auto-resolves to the active lens). + +Returns: + +- **`custom_fields`** — one row per value: `{id, name, type, value}`. The lead + payload embeds each field's definition, so rows are fully named without a + separate catalog lookup. Empty array when the org has no custom fields or + none are set on this lead. +- **`count`** — number of values. +- **`hint`** — empty-state guidance (points at `leadbay_list_mappable_fields` + and the import path), or a degradation note in the rare case a value arrived + without an embedded definition and the catalog fallback failed. + +Reading a lead marks it seen (LEAD_SEEN) the same way the research tools do, so +it ages out of the 'new' Discover view. + +Companion tools: **leadbay_list_mappable_fields** for the catalog/definitions; +**leadbay_research_lead_by_id** for the full lead dossier (signals, contacts, +qualification answers). + +### RENDERING + +Render `custom_fields` as a 3-column markdown table: **Field** (`name`, or the +`id` when name is null) · **Type** (`type`) · **Value** (`value`, or "—" when +null/empty). One row per entry, in the order returned. When `custom_fields` is +empty, render the `hint` sentence instead of an empty table. Don't fabricate +fields or values — render verbatim. diff --git a/packages/promptforge/tool-descriptions/composite/get-qualification-methods.md.tmpl b/packages/promptforge/tool-descriptions/composite/get-qualification-methods.md.tmpl new file mode 100644 index 0000000..cb94d87 --- /dev/null +++ b/packages/promptforge/tool-descriptions/composite/get-qualification-methods.md.tmpl @@ -0,0 +1,76 @@ +--- +name: leadbay_get_qualification_methods +kind: tool-description +short_description: | + Retrieve the org's qualification methods — the AI-agent questions Leadbay + scores every lead against. Use when the user wants to know HOW their leads + are being qualified at the org level. Don't use it for one lead's + qualification ANSWERS (that's leadbay_research_lead_by_id) or for the broader + buyer profile + intent tags (that's leadbay_get_taste_profile). +routing: + triggers: + - "what are my qualification methods" + - "what questions does Leadbay ask about each lead" + - "show me the org qualification questions" + - "how are my leads being qualified" + - "what's the qualification criteria" + anti_triggers: + - phrase: "how did this lead score on the qualification questions" + route_to: leadbay_research_lead_by_id + - phrase: "show my ideal buyer profile and intent tags" + route_to: leadbay_get_taste_profile + prefer_when: "user wants the ORG-level qualification questions catalog, no lead and no buyer profile" + examples: + positive: + - "What qualification questions does Leadbay use to score my leads?" + - "Show me my org's qualification methods." + negative: + - "How did Acme Corp answer the qualification questions?" + - "What's my ideal buyer profile?" +rendering_hint: | + Numbered list of the questions (chat-native markdown), each one line. When + `is_admin` is true, append the `hint` as a footnote (points at + leadbay_set_qualification_methods for editing). When the list is empty, + render the `hint` instead. +annotations: + readOnlyHint: true + destructiveHint: false + idempotentHint: true + openWorldHint: true +--- +Retrieve the organization's **qualification methods** — the AI-agent questions +Leadbay scores every lead against. These are the org-level questions that drive +each lead's qualification boost; the per-lead ANSWERS to them surface inside +`leadbay_research_lead_by_id`. + +Returns: + +- **`qualification_questions`** — the catalog. Each: `{question, created_at, + lang}`. Ordered as the backend returns them. +- **`count`** — number of configured questions. +- **`is_admin`** — whether the current user is an org admin. Modifying the + questions (`leadbay_set_qualification_methods`) is an org-admin action; for + admins a `hint` points there. +- **`hint`** — operator note: the modify pointer, or an empty-state message + when no questions are configured. + +This tool only READS. To change the questions, use +**leadbay_set_qualification_methods** (add / remove / replace). The result is +cached on the client (it reuses the same taste-profile fetch as +`leadbay_get_taste_profile`), so repeated calls in a session are cheap. + +Companion tools: **leadbay_set_qualification_methods** to modify the questions; +**leadbay_get_taste_profile** when the user also wants the Ideal Buyer Profile + +purchase-intent tags; **leadbay_research_lead_by_id** for how a SPECIFIC lead +answered these questions; **leadbay_refine_prompt** to shape the AI agent's +behaviour. + +### RENDERING + +Render `qualification_questions` as a numbered list — one question per line, in +the order returned. Lead with a short heading like **"Qualification methods +(N)"**. When `qualification_questions` is empty, render the `hint` sentence +instead of an empty list. When `is_admin` is true and there are questions, +append the `hint` as a one-line footnote (points at +leadbay_set_qualification_methods). Do not invent questions or reword them — +render verbatim. diff --git a/packages/promptforge/tool-descriptions/composite/set-qualification-methods.md.tmpl b/packages/promptforge/tool-descriptions/composite/set-qualification-methods.md.tmpl new file mode 100644 index 0000000..ca31fd2 --- /dev/null +++ b/packages/promptforge/tool-descriptions/composite/set-qualification-methods.md.tmpl @@ -0,0 +1,34 @@ +--- +name: leadbay_set_qualification_methods +kind: tool-description +short_description: | + Modify the org's qualification methods — the AI-agent questions every lead is + scored against. Add, remove, or replace questions. Removing requires + confirm:true. Read them first with leadbay_get_qualification_methods. +annotations: + readOnlyHint: false + destructiveHint: true + idempotentHint: false + openWorldHint: true +--- +Modify the organization's **qualification methods** — the AI-agent questions Leadbay scores every lead against. Use when the user wants to add, remove, or rewrite their qualification questions — e.g. "add a question about whether they run install crews", "remove the flooring question", "replace my questions with these three". + +The backend stores the list as a whole, so this tool reads the current questions and applies your change: + +- **`add`** — append questions (deduped against the current list). +- **`remove`** — drop the exact question strings you pass. +- **`questions`** — replace the ENTIRE list (mutually exclusive with add/remove). + +Leadbay allows **at most 5** qualification questions. If a change would exceed 5, the tool rejects with a clear limit message — remove some before adding. + +**Removing or shrinking the list is destructive** — it changes how every lead is scored. Any change that ends with FEWER questions than before requires `confirm:true`; without it the tool previews what would be removed and applies nothing. Adding questions does not need confirm. + +Returns the resulting `{qualification_questions, count, previous_count, changed}`. Phrase questions as the yes/no scoring prompts Leadbay uses (e.g. "Is the company likely to …?"). + +{{include:headers/tool-when-to-use}} the user wants to change the org's qualification questions. + +{{include:headers/tool-when-not-to-use}} to READ the questions (use leadbay_get_qualification_methods) or to change a single lead's data. This is org-level — it affects scoring for ALL leads. + +### RENDERING + +After a change, confirm in one line — e.g. **"Added 1 question — you now score leads against 4 questions."** or **"Removed 'the flooring question' — 3 questions remain."** Then list the resulting questions as a numbered list. On an unconfirmed shrink, surface the `hint` (what would be removed) and ask the user to confirm — do NOT auto-confirm. diff --git a/packages/promptforge/tool-descriptions/composite/update-custom-field.md.tmpl b/packages/promptforge/tool-descriptions/composite/update-custom-field.md.tmpl new file mode 100644 index 0000000..ecb4efe --- /dev/null +++ b/packages/promptforge/tool-descriptions/composite/update-custom-field.md.tmpl @@ -0,0 +1,25 @@ +--- +name: leadbay_update_custom_field +kind: tool-description +short_description: | + Update an existing org CRM custom field — rename it and/or change its type + + config. Pass the field id from leadbay_list_mappable_fields. +annotations: + readOnlyHint: false + destructiveHint: false + idempotentHint: true + openWorldHint: true +--- +Update an org-level CRM custom field in place. Use when the user wants to rename a custom field or change its type/config — e.g. "rename the 'Tier' field to 'Account Tier'" or "make the ARR field a PRICE in USD". + +Pass `id` (the numeric custom-field id from `leadbay_list_mappable_fields` — NOT the `CUSTOM.` mapping value) plus any of `name`, `type`, `config`. The update is a partial merge over the current definition: a rename-only call keeps the existing type; a retype-only call keeps the name. At least one of `name` / `type` / `config` is required. + +Type + config rules mirror creation: `EXTERNAL_ID` requires `config.url_template` containing `{value}`; `PRICE` requires `config.currency`; `DATE`/`DATETIME` may set `config.format`. Returns the updated `{id, name, type, config, mapping_value}`. + +{{include:headers/tool-when-to-use}} the user wants to change an existing custom field's name or type. + +{{include:headers/tool-when-not-to-use}} to CREATE a new field (use leadbay_create_custom_field) or to DELETE one (use leadbay_delete_custom_field). To read a lead's custom-field values use leadbay_get_lead_custom_fields; to list the catalog use leadbay_list_mappable_fields. + +### RENDERING + +Confirm the change in one line naming the field and what changed, e.g. **"Renamed custom field #12 → 'Account Tier' (TEXT)."** or **"Updated #13 'ARR' → PRICE (USD)."** Don't dump the raw payload.