From d1efce9b414409b0344ebce802c6aea498b0d267 Mon Sep 17 00:00:00 2001 From: ArtyETH06 Date: Fri, 19 Jun 2026 11:44:51 -0700 Subject: [PATCH 01/17] feat(core): retrieve org qualification methods + per-lead custom fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new default-surface read composites resolving product#3768: - leadbay_get_qualification_methods — returns the org's AI-agent qualification questions (the "qualification methods") with created_at + lang, plus the caller's is_admin flag and a web-app edit hint. Read-only for everyone; the API exposes no write endpoint for these questions. - leadbay_get_lead_custom_fields — returns the CRM custom-field VALUES on a single lead as {id, name, type, value}. The lead payload embeds each field's definition (verified live), so no /crm/custom_fields join is needed; the catalog is fetched only as a defensive fallback. Fires LEAD_SEEN on read, in parity with the research tools. Both were already partially reachable only via the ADVANCED-gated get_taste_profile / list_mappable_fields (definitions only); the per-lead custom_fields array was silently dropped (untyped on LeadPayload). Adds LeadCustomFieldEntry + LeadPayload.custom_fields, two routing templates, WORKFLOWS.md rows, and registry/audit entries (COMPOSITE_FILE_TOOL_NAMES, TOOLS_WITH_ROUTING, output-schema conformance CASES). Verified live end-to-end on the test org. Co-Authored-By: Claude --- WORKFLOWS.md | 2 + .../src/composite/_composite-file-names.ts | 2 + .../src/composite/get-lead-custom-fields.ts | 158 ++++++++++++++++++ .../composite/get-qualification-methods.ts | 95 +++++++++++ packages/core/src/index.ts | 12 ++ .../core/src/tool-descriptions.generated.ts | 130 ++++++++++++++ packages/core/src/types.ts | 14 ++ .../composite/get-lead-custom-fields.test.ts | 124 ++++++++++++++ .../get-qualification-methods.test.ts | 82 +++++++++ packages/mcp/test/audit/routing-block.test.ts | 2 + .../test/output-schema-conformance.test.ts | 81 +++++++++ .../composite/get-lead-custom-fields.md.tmpl | 74 ++++++++ .../get-qualification-methods.md.tmpl | 72 ++++++++ 13 files changed, 848 insertions(+) create mode 100644 packages/core/src/composite/get-lead-custom-fields.ts create mode 100644 packages/core/src/composite/get-qualification-methods.ts create mode 100644 packages/core/test/unit/composite/get-lead-custom-fields.test.ts create mode 100644 packages/core/test/unit/composite/get-qualification-methods.test.ts create mode 100644 packages/promptforge/tool-descriptions/composite/get-lead-custom-fields.md.tmpl create mode 100644 packages/promptforge/tool-descriptions/composite/get-qualification-methods.md.tmpl diff --git a/WORKFLOWS.md b/WORKFLOWS.md index f5e80bce..90a29bcf 100644 --- a/WORKFLOWS.md +++ b/WORKFLOWS.md @@ -41,6 +41,8 @@ The table is the human-readable index. The `yaml expected` + `yaml scenario` blo | 27 | **Prior-context carry-over** — across turns the agent must reuse the lead_id it surfaced earlier rather than re-running discovery | `leadbay_daily_check_in` | *(multi-turn — see `turns:` contract)* | | 28 | **Send feedback to the team** — "send feedback", "report a bug", "tell Leadbay…", or accepting an offer to report an error — delivers a user-authored message to the Leadbay team's Sentry feedback inbox (same destination as the web app's feedback form) | `leadbay_send_feedback` | "Send feedback to the team: lead scores feel off this week" | | 29 | **Audience build from dirty taxonomy (no-crash)** — "create a group for menuisiers, pergolas, vérandas" — `leadbay_adjust_audience` must tolerate a null-name sector-taxonomy row and ambiguous matches, returning a graceful ambiguous-sectors message rather than a TypeError (regression lock for the v0.17.3 sector-creation crash) | `leadbay_adjust_audience` | "Create a group for menuisiers, pergolas, vérandas" | +| 30 | **Org qualification methods** — "what qualification questions does Leadbay use", "how are my leads qualified" — retrieve the org-level AI-agent question catalog (read-only; editing is web-app only) | `leadbay_get_qualification_methods` | "What qualification questions does Leadbay use to score my leads?" | +| 31 | **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?" | --- diff --git a/packages/core/src/composite/_composite-file-names.ts b/packages/core/src/composite/_composite-file-names.ts index 6355e7bf..7915e1c9 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", 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 00000000..df799936 --- /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 00000000..7a984675 --- /dev/null +++ b/packages/core/src/composite/get-qualification-methods.ts @@ -0,0 +1,95 @@ +import type { LeadbayClient } from "../client.js"; +import type { Tool, ToolContext } 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 for everyone; editing the +// questions has no MCP write endpoint today (done in the Leadbay web app), +// so for admins we surface a hint instead of a (non-existent) mutate path. +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 edit qualification questions in the Leadbay web app (no MCP write endpoint yet).", + }, + 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 before + // resolveTasteProfile (which resolves the org id from the same /me) and + // before withAgentMemoryMeta reuse it — avoids a concurrent double-fetch. + // Both are best-effort for the role flag. + const me = await client.resolveMe().catch(() => null); + const profile = await client.resolveTasteProfile(); + + const questions = profile.qualificationQuestions ?? []; + const isAdmin = me?.admin ?? false; + + let hint: string | undefined; + if (questions.length === 0) { + hint = + "No qualification questions configured yet. Use leadbay_refine_prompt to shape the AI agent, or set them up in the Leadbay web app for better lead scoring."; + } else if (isAdmin) { + hint = + "You're an org admin — qualification questions are currently editable in the Leadbay web app (no MCP edit tool yet)."; + } + + 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/index.ts b/packages/core/src/index.ts index ecb62266..597f8cb1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -98,6 +98,8 @@ 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 { 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"; @@ -164,6 +166,7 @@ export { // new composite reads pullLeads, pullFollowups, followupsMap, tourPlan, listCampaigns, campaignProgression, campaignCallSheet, researchLeadById, researchLeadByNameFuzzy, + getQualificationMethods, getLeadCustomFields, accountHistory, recallOrderedTitles, accountStatus, scanPortfolioSignals, bulkEnrichStatus, qualifyStatus, importStatus, resolveImportRows, @@ -260,6 +263,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 diff --git a/packages/core/src/tool-descriptions.generated.ts b/packages/core/src/tool-descriptions.generated.ts index 7a96a107..ac2a2e86 100644 --- a/packages/core/src/tool-descriptions.generated.ts +++ b/packages/core/src/tool-descriptions.generated.ts @@ -1306,6 +1306,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. @@ -1351,6 +1416,69 @@ 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 pointing at the web app +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. Editing the + questions is currently done in the Leadbay web app (there is no MCP edit + endpoint yet); for admins a \`hint\` points this out. +- **\`hint\`** — operator note: the admin edit pointer, or an empty-state message + when no questions are configured. + +The questions are read-only here regardless of role. 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_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 (editing happens in the web app). 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\`. @@ -3904,11 +4032,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, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index b78d4dcf..e0413419 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 }>; @@ -559,6 +563,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 00000000..d3113236 --- /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.test.ts b/packages/core/test/unit/composite/get-qualification-methods.test.ts new file mode 100644 index 00000000..1461b9d7 --- /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 + the web-app edit hint", async () => { + mockHttp([mockMe(true), ...mockTaste(QUESTIONS)]); + const res: any = await getQualificationMethods.execute(newClient(), {}); + + expect(res.is_admin).toBe(true); + expect(res.hint).toMatch(/web app/i); + }); + + 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/mcp/test/audit/routing-block.test.ts b/packages/mcp/test/audit/routing-block.test.ts index f6912f8a..d280c744 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 7b5e0499..51773d0e 100644 --- a/packages/mcp/test/output-schema-conformance.test.ts +++ b/packages/mcp/test/output-schema-conformance.test.ts @@ -161,6 +161,87 @@ interface ConformanceCase { } const CASES: ConformanceCase[] = [ + { + 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/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 00000000..ef0db8ce --- /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 00000000..95bba83e --- /dev/null +++ b/packages/promptforge/tool-descriptions/composite/get-qualification-methods.md.tmpl @@ -0,0 +1,72 @@ +--- +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 pointing at the web app + 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. Editing the + questions is currently done in the Leadbay web app (there is no MCP edit + endpoint yet); for admins a `hint` points this out. +- **`hint`** — operator note: the admin edit pointer, or an empty-state message + when no questions are configured. + +The questions are read-only here regardless of role. 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_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 (editing happens in the web app). Do +not invent questions or reword them — render verbatim. From 3b4254c54c2e0502aff773acce776b8ff231fac6 Mon Sep 17 00:00:00 2001 From: ArtyETH06 Date: Fri, 19 Jun 2026 11:51:07 -0700 Subject: [PATCH 02/17] test(eval): add eval contracts for qualification-methods + lead-custom-fields Adds the machine-readable yaml expected + yaml scenario blocks for workflows #30 (Org qualification methods) and #31 (Per-lead custom-field values) so /eval can run them. Live run: both 5/5/5/5, invariants green. Co-Authored-By: Claude --- WORKFLOWS.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/WORKFLOWS.md b/WORKFLOWS.md index 90a29bcf..2f096645 100644 --- a/WORKFLOWS.md +++ b/WORKFLOWS.md @@ -503,6 +503,42 @@ 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." +``` + --- ## Needs backend From 1f8cd4419865f03d606919885f92f532e54cf216 Mon Sep 17 00:00:00 2001 From: ArtyETH06 Date: Fri, 19 Jun 2026 15:16:37 -0700 Subject: [PATCH 03/17] feat(core): update + delete CRM custom fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the modify surface for custom fields, completing the read+write set: - leadbay_update_custom_field — rename and/or change type+config in place (POST /crm/custom_fields/{id} → 204, verified live). Partial-merge over the current definition so rename-only keeps the type and retype-only keeps the name. Same EXTERNAL_ID/PRICE config validation as create. - leadbay_delete_custom_field — DELETE /crm/custom_fields/{id} → 204. Destructive (drops the field's values from every lead), so it requires an explicit confirm:true; without it the tool previews the field and does nothing. destructiveHint:true. Both granular-shaped, registered in compositeWriteTools (default surface, write-gated like create_custom_field). Conformance CASES + new unit test files added; live update+delete round-trip verified. Qualification methods intentionally get NO modify tool — the API exposes no write endpoint for ai_agent_questions (every verb 404s); they stay read-only with the web-app edit hint. Co-Authored-By: Claude --- packages/core/src/index.ts | 10 +- .../core/src/tool-descriptions.generated.ts | 39 +++++ .../core/src/tools/delete-custom-field.ts | 108 +++++++++++++ .../core/src/tools/update-custom-field.ts | 151 ++++++++++++++++++ .../unit/tools/delete-custom-field.test.ts | 55 +++++++ .../unit/tools/update-custom-field.test.ts | 70 ++++++++ .../test/output-schema-conformance.test.ts | 40 +++++ .../composite/delete-custom-field.md.tmpl | 29 ++++ .../composite/update-custom-field.md.tmpl | 25 +++ 9 files changed, 526 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/tools/delete-custom-field.ts create mode 100644 packages/core/src/tools/update-custom-field.ts create mode 100644 packages/core/test/unit/tools/delete-custom-field.test.ts create mode 100644 packages/core/test/unit/tools/update-custom-field.test.ts create mode 100644 packages/promptforge/tool-descriptions/composite/delete-custom-field.md.tmpl create mode 100644 packages/promptforge/tool-descriptions/composite/update-custom-field.md.tmpl diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 597f8cb1..5c7b269c 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 @@ -160,7 +162,7 @@ export { clearUserPrompt, pickClarification, dismissClarification, setEpilogueStatus, removeEpilogue, setPushback, removePushback, previewBulkEnrichment, launchBulkEnrichment, likeLead, dislikeLead, - createCustomField, + createCustomField, updateCustomField, deleteCustomField, // existing composite prepareOutreach, // new composite reads @@ -357,6 +359,12 @@ 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. The qualification-questions counterpart has no + // API write endpoint, so there is intentionally no modify tool for those. + updateCustomField, + deleteCustomField, // 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 ac2a2e86..18b0f8a2 100644 --- a/packages/core/src/tool-descriptions.generated.ts +++ b/packages/core/src/tool-descriptions.generated.ts @@ -864,6 +864,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. @@ -3973,6 +3993,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. @@ -4019,6 +4056,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, @@ -4089,6 +4127,7 @@ export const TOOL_DESCRIPTIONS = { 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/delete-custom-field.ts b/packages/core/src/tools/delete-custom-field.ts new file mode 100644 index 00000000..c8ac1e8c --- /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 00000000..c4b27039 --- /dev/null +++ b/packages/core/src/tools/update-custom-field.ts @@ -0,0 +1,151 @@ +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"; + +interface UpdateCustomFieldParams { + id: string; + name?: string; + type?: CustomCrmFieldKind; + config?: CustomCrmFieldConfig | 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", "null"], + description: + "New type-specific config. 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 config = + params.config !== undefined ? params.config : (current.config ?? null); + + if (type === "EXTERNAL_ID") { + const urlTemplate = config?.url_template ?? config?.urlTemplate; + 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/test/unit/tools/delete-custom-field.test.ts b/packages/core/test/unit/tools/delete-custom-field.test.ts new file mode 100644 index 00000000..7cb09bd4 --- /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 00000000..0b6cd3dd --- /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/test/output-schema-conformance.test.ts b/packages/mcp/test/output-schema-conformance.test.ts index 51773d0e..f06d93bf 100644 --- a/packages/mcp/test/output-schema-conformance.test.ts +++ b/packages/mcp/test/output-schema-conformance.test.ts @@ -161,6 +161,46 @@ interface ConformanceCase { } const CASES: ConformanceCase[] = [ + { + 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: {}, 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 00000000..a04c4981 --- /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/update-custom-field.md.tmpl b/packages/promptforge/tool-descriptions/composite/update-custom-field.md.tmpl new file mode 100644 index 00000000..ecb4efeb --- /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. From 8c0ad3041e4d563d4c19f116069d256eb912dfc2 Mon Sep 17 00:00:00 2001 From: ArtyETH06 Date: Fri, 19 Jun 2026 15:26:57 -0700 Subject: [PATCH 04/17] feat(core): modify org qualification methods (write) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The qualification-questions write endpoint DOES exist — it's the org root POST /organizations/{orgId} with {ai_agent_lead_questions:[string,...]} → 204 (full-replace; confirmed live with the web app's exact payload). My earlier 404 was probing the wrong path (/ai_agent_questions). Adds leadbay_set_qualification_methods: - set / add / remove modes; reads the current list and posts the full resulting array (matches the full-replace endpoint). - shrinking the list requires confirm:true (removing a question changes how every lead is scored); add does not. - enforces the backend cap (max 5 questions) with an actionable hint instead of a raw 400. - invalidates the taste-profile cache after a write. Live-verified: add hits the 5-cap correctly; remove-without-confirm previews; remove+restore round-trips cleanly (account left as found). This supersedes the earlier "no write endpoint" note — qualification methods are now fully modifiable, alongside custom fields. Co-Authored-By: Claude --- .../src/composite/_composite-file-names.ts | 1 + .../composite/set-qualification-methods.ts | 226 ++++++++++++++++++ packages/core/src/index.ts | 9 +- .../core/src/tool-descriptions.generated.ts | 26 ++ .../set-qualification-methods.test.ts | 119 +++++++++ .../test/output-schema-conformance.test.ts | 26 ++ .../set-qualification-methods.md.tmpl | 34 +++ 7 files changed, 439 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/composite/set-qualification-methods.ts create mode 100644 packages/core/test/unit/composite/set-qualification-methods.test.ts create mode 100644 packages/promptforge/tool-descriptions/composite/set-qualification-methods.md.tmpl diff --git a/packages/core/src/composite/_composite-file-names.ts b/packages/core/src/composite/_composite-file-names.ts index 7915e1c9..c86147ac 100644 --- a/packages/core/src/composite/_composite-file-names.ts +++ b/packages/core/src/composite/_composite-file-names.ts @@ -44,5 +44,6 @@ 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_tour_plan", ]); 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 00000000..4763a50e --- /dev/null +++ b/packages/core/src/composite/set-qualification-methods.ts @@ -0,0 +1,226 @@ +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 + ); + } + + // Shrinking the list is destructive — require confirm. + if (next.length < previousCount && params.confirm !== true) { + const removed = currentQs.filter((q) => !next.some((n) => norm(n) === norm(q))); + 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 ${previousCount - next.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 5c7b269c..6ebb88fa 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -101,6 +101,7 @@ 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"; @@ -169,6 +170,7 @@ export { pullLeads, pullFollowups, followupsMap, tourPlan, listCampaigns, campaignProgression, campaignCallSheet, researchLeadById, researchLeadByNameFuzzy, getQualificationMethods, getLeadCustomFields, + setQualificationMethods, accountHistory, recallOrderedTitles, accountStatus, scanPortfolioSignals, bulkEnrichStatus, qualifyStatus, importStatus, resolveImportRows, @@ -361,10 +363,13 @@ export const compositeWriteTools: Tool[] = [ 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. The qualification-questions counterpart has no - // API write endpoint, so there is intentionally no modify tool for those. + // 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 18b0f8a2..63f6c288 100644 --- a/packages/core/src/tool-descriptions.generated.ts +++ b/packages/core/src/tool-descriptions.generated.ts @@ -3799,6 +3799,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. @@ -4123,6 +4148,7 @@ export const TOOL_DESCRIPTIONS = { leadbay_set_active_lens, leadbay_set_epilogue_status, leadbay_set_pushback, + leadbay_set_qualification_methods, leadbay_set_user_prompt, leadbay_tour_plan, leadbay_unpin_contact, 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 00000000..faaac4f2 --- /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/mcp/test/output-schema-conformance.test.ts b/packages/mcp/test/output-schema-conformance.test.ts index f06d93bf..26d8f399 100644 --- a/packages/mcp/test/output-schema-conformance.test.ts +++ b/packages/mcp/test/output-schema-conformance.test.ts @@ -161,6 +161,32 @@ 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" }, 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 00000000..ca31fd2a --- /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. From 2bcb871aedba70ea70ed9b594beb59d682a493f4 Mon Sep 17 00:00:00 2001 From: ArtyETH06 Date: Fri, 19 Jun 2026 15:31:39 -0700 Subject: [PATCH 05/17] test(eval): add eval contract for modify-qualification-methods (wf 32) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds workflow #32 (Modify qualification methods) exercising leadbay_set_qualification_methods, and drops the now-stale "read-only" note from #30. Scenario adds a question against an org already at the 5-question cap — exercises the write tool + cap handling while mutating nothing. Live run: 5/5/5/5, invariants 2/2, org questions byte-for-byte unchanged after. Co-Authored-By: Claude --- WORKFLOWS.md | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/WORKFLOWS.md b/WORKFLOWS.md index 2f096645..b3700d01 100644 --- a/WORKFLOWS.md +++ b/WORKFLOWS.md @@ -41,8 +41,9 @@ The table is the human-readable index. The `yaml expected` + `yaml scenario` blo | 27 | **Prior-context carry-over** — across turns the agent must reuse the lead_id it surfaced earlier rather than re-running discovery | `leadbay_daily_check_in` | *(multi-turn — see `turns:` contract)* | | 28 | **Send feedback to the team** — "send feedback", "report a bug", "tell Leadbay…", or accepting an offer to report an error — delivers a user-authored message to the Leadbay team's Sentry feedback inbox (same destination as the web app's feedback form) | `leadbay_send_feedback` | "Send feedback to the team: lead scores feel off this week" | | 29 | **Audience build from dirty taxonomy (no-crash)** — "create a group for menuisiers, pergolas, vérandas" — `leadbay_adjust_audience` must tolerate a null-name sector-taxonomy row and ambiguous matches, returning a graceful ambiguous-sectors message rather than a TypeError (regression lock for the v0.17.3 sector-creation crash) | `leadbay_adjust_audience` | "Create a group for menuisiers, pergolas, vérandas" | -| 30 | **Org qualification methods** — "what qualification questions does Leadbay use", "how are my leads qualified" — retrieve the org-level AI-agent question catalog (read-only; editing is web-app only) | `leadbay_get_qualification_methods` | "What qualification questions does Leadbay use to score my leads?" | +| 30 | **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?" | | 31 | **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?" | +| 32 | **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` | "Add a qualification question: is the company a flooring distributor?" | --- @@ -539,6 +540,24 @@ success_criteria: 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: + - "called leadbay_set_qualification_methods to add the requested question (add mode), not a read tool" + - "reported the outcome truthfully from the tool result — if the org is at the 5-question cap, surfaced that the question was NOT added and that an existing one must be removed first (did not falsely claim success)" + - "did NOT silently drop or replace the org's existing questions, and did NOT invent a confirmation that the question was saved when the tool reported it was not" + - "did NOT call leadbay_create_custom_field (qualification questions are not custom fields)" +``` + +```yaml scenario +prompt: "Add a qualification question: is the company a flooring distributor?" +``` + --- ## Needs backend From 7b1e519c930c9244c11094d358466203c44202b9 Mon Sep 17 00:00:00 2001 From: ArtyETH06 Date: Fri, 19 Jun 2026 15:36:04 -0700 Subject: [PATCH 06/17] test(eval): add eval contract for modify-custom-fields (wf 33) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds workflow #33 (Modify custom fields) exercising the full create → update → delete lifecycle, including the delete confirm-gate. Self-contained: the scenario creates a throwaway field, renames it, and deletes it with confirm, so the run cleans up after itself. Live run: 5/5/5/5, invariants 3/3, catalog byte-for-byte unchanged after (only the pre-existing field remains). Co-Authored-By: Claude --- WORKFLOWS.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/WORKFLOWS.md b/WORKFLOWS.md index b3700d01..bd98a105 100644 --- a/WORKFLOWS.md +++ b/WORKFLOWS.md @@ -44,6 +44,7 @@ The table is the human-readable index. The `yaml expected` + `yaml scenario` blo | 30 | **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?" | | 31 | **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?" | | 32 | **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` | "Add a qualification question: is the company a flooring distributor?" | +| 33 | **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." | --- @@ -558,6 +559,24 @@ success_criteria: prompt: "Add a qualification question: is the company a flooring distributor?" ``` +```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." +``` + --- ## Needs backend From 37deedf64526e8485b4ca82bc7b6b8d79adf9ecd Mon Sep 17 00:00:00 2001 From: ArtyETH06 Date: Mon, 22 Jun 2026 10:37:54 -0700 Subject: [PATCH 07/17] fix(core): point get_qualification_methods at the modify tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The read tool's description + admin hint still said editing happens in the Leadbay web app with "no MCP edit tool yet" — stale since leadbay_set_qualification_methods landed. Repoint the hint, is_admin doc, description, and rendering footnote at leadbay_set_qualification_methods. Confirmed against the backend repo: both modify surfaces (qualification questions + custom fields) are authenticate("admin"), i.e. org-admin only — which every user is for their own org, so the modify tools work for everyone in practice. No per-lead custom-field VALUE write route exists (values flow through the import pipeline only). Co-Authored-By: Claude --- .../composite/get-qualification-methods.ts | 12 +++---- .../core/src/tool-descriptions.generated.ts | 32 +++++++++++-------- .../get-qualification-methods.test.ts | 4 +-- .../get-qualification-methods.md.tmpl | 32 +++++++++++-------- 4 files changed, 44 insertions(+), 36 deletions(-) diff --git a/packages/core/src/composite/get-qualification-methods.ts b/packages/core/src/composite/get-qualification-methods.ts index 7a984675..2f4a540f 100644 --- a/packages/core/src/composite/get-qualification-methods.ts +++ b/packages/core/src/composite/get-qualification-methods.ts @@ -6,9 +6,9 @@ import { leadbay_get_qualification_methods as GET_QUALIFICATION_METHODS_DESCRIPT // 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 for everyone; editing the -// questions has no MCP write endpoint today (done in the Leadbay web app), -// so for admins we surface a hint instead of a (non-existent) mutate path. +// (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: { @@ -40,7 +40,7 @@ export const getQualificationMethods: Tool> = { is_admin: { type: "boolean", description: - "Whether the current bearer-token holder is an org admin. Admins edit qualification questions in the Leadbay web app (no MCP write endpoint yet).", + "Whether the current bearer-token holder is an org admin. Admins can modify the questions via leadbay_set_qualification_methods.", }, region: { type: "string" }, hint: { @@ -70,10 +70,10 @@ export const getQualificationMethods: Tool> = { let hint: string | undefined; if (questions.length === 0) { hint = - "No qualification questions configured yet. Use leadbay_refine_prompt to shape the AI agent, or set them up in the Leadbay web app for better lead scoring."; + "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 — qualification questions are currently editable in the Leadbay web app (no MCP edit tool yet)."; + "You're an org admin — use leadbay_set_qualification_methods to add, remove, or replace these questions."; } return withAgentMemoryMeta( diff --git a/packages/core/src/tool-descriptions.generated.ts b/packages/core/src/tool-descriptions.generated.ts index 63f6c288..43920b64 100644 --- a/packages/core/src/tool-descriptions.generated.ts +++ b/packages/core/src/tool-descriptions.generated.ts @@ -1458,8 +1458,9 @@ Examples that should NOT invoke this tool (sound similar, route elsewhere): ## RENDER (quick) Numbered list of the questions (chat-native markdown), each one line. When -\`is_admin\` is true, append the \`hint\` as a footnote pointing at the web app -for editing. When the list is empty, render the \`hint\` instead. +\`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. --- @@ -1473,20 +1474,22 @@ 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. Editing the - questions is currently done in the Leadbay web app (there is no MCP edit - endpoint yet); for admins a \`hint\` points this out. -- **\`hint\`** — operator note: the admin edit pointer, or an empty-state message +- **\`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. -The questions are read-only here regardless of role. The result is cached on -the client (it reuses the same taste-profile fetch as +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_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. +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 @@ -1494,8 +1497,9 @@ Render \`qualification_questions\` as a numbered list — one question per line, 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 (editing happens in the web app). Do -not invent questions or reword them — render verbatim. +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 diff --git a/packages/core/test/unit/composite/get-qualification-methods.test.ts b/packages/core/test/unit/composite/get-qualification-methods.test.ts index 1461b9d7..075b301e 100644 --- a/packages/core/test/unit/composite/get-qualification-methods.test.ts +++ b/packages/core/test/unit/composite/get-qualification-methods.test.ts @@ -56,12 +56,12 @@ describe("leadbay_get_qualification_methods", () => { expect(res.hint).toBeUndefined(); }); - it("admin user — surfaces is_admin + the web-app edit hint", async () => { + 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(/web app/i); + expect(res.hint).toMatch(/leadbay_set_qualification_methods/); }); it("empty catalog — empty array + empty-state hint", async () => { diff --git a/packages/promptforge/tool-descriptions/composite/get-qualification-methods.md.tmpl b/packages/promptforge/tool-descriptions/composite/get-qualification-methods.md.tmpl index 95bba83e..cb94d877 100644 --- a/packages/promptforge/tool-descriptions/composite/get-qualification-methods.md.tmpl +++ b/packages/promptforge/tool-descriptions/composite/get-qualification-methods.md.tmpl @@ -29,8 +29,9 @@ routing: - "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 pointing at the web app - for editing. When the list is empty, render the `hint` instead. + `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 @@ -47,20 +48,22 @@ 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. Editing the - questions is currently done in the Leadbay web app (there is no MCP edit - endpoint yet); for admins a `hint` points this out. -- **`hint`** — operator note: the admin edit pointer, or an empty-state message +- **`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. -The questions are read-only here regardless of role. The result is cached on -the client (it reuses the same taste-profile fetch as +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_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. +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 @@ -68,5 +71,6 @@ Render `qualification_questions` as a numbered list — one question per line, i 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 (editing happens in the web app). Do -not invent questions or reword them — render verbatim. +append the `hint` as a one-line footnote (points at +leadbay_set_qualification_methods). Do not invent questions or reword them — +render verbatim. From 67844361d07085b77c763d5446723224d2a2f65f Mon Sep 17 00:00:00 2001 From: ArtyETH06 Date: Mon, 22 Jun 2026 11:04:54 -0700 Subject: [PATCH 08/17] test(eval): make wf32 a self-restoring round-trip The previous wf32 scenario ("add a question" against a capped list) let the agent self-confirm a destructive removal to make room, mutating live data. Rewrite it as a remove-then-readd round-trip on a single named question: it nets back to the original set, exercises remove(confirm)+add, and never leaves the org mutated. Live re-run: 5/5/5/5, question set byte-for-byte unchanged. Co-Authored-By: Claude --- WORKFLOWS.md | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/WORKFLOWS.md b/WORKFLOWS.md index bd98a105..a502c94a 100644 --- a/WORKFLOWS.md +++ b/WORKFLOWS.md @@ -43,7 +43,7 @@ The table is the human-readable index. The `yaml expected` + `yaml scenario` blo | 29 | **Audience build from dirty taxonomy (no-crash)** — "create a group for menuisiers, pergolas, vérandas" — `leadbay_adjust_audience` must tolerate a null-name sector-taxonomy row and ambiguous matches, returning a graceful ambiguous-sectors message rather than a TypeError (regression lock for the v0.17.3 sector-creation crash) | `leadbay_adjust_audience` | "Create a group for menuisiers, pergolas, vérandas" | | 30 | **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?" | | 31 | **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?" | -| 32 | **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` | "Add a qualification question: is the company a flooring distributor?" | +| 32 | **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." | | 33 | **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." | --- @@ -549,14 +549,18 @@ required_calls: forbidden_calls: - leadbay_create_custom_field success_criteria: - - "called leadbay_set_qualification_methods to add the requested question (add mode), not a read tool" - - "reported the outcome truthfully from the tool result — if the org is at the 5-question cap, surfaced that the question was NOT added and that an existing one must be removed first (did not falsely claim success)" - - "did NOT silently drop or replace the org's existing questions, and did NOT invent a confirmation that the question was saved when the tool reported it was not" - - "did NOT call leadbay_create_custom_field (qualification questions are not custom fields)" + - "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: "Add a qualification question: is the company a flooring distributor?" +prompt: "Remove the qualification question 'hghg', then add it back exactly as it was." ``` ```yaml expected From c847b82b948623a32ed6e8e0a372c5a0e457ff9c Mon Sep 17 00:00:00 2001 From: ArtyETH06 Date: Mon, 22 Jun 2026 11:51:15 -0700 Subject: [PATCH 09/17] fix(mcp): reconstruct XDG_RUNTIME_DIR + DBUS for OAuth browser-open on Wayland/Snap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OAuth-on-install (.dxt) spawned xdg-open "successfully" but no tab opened on Wayland + Snap-browser setups (the Ubuntu default). Claude Desktop strips XDG_RUNTIME_DIR — which broke the existing WAYLAND_DISPLAY reconstruction (it reads that dir) — and strips DBUS_SESSION_BUS_ADDRESS, so a Snap/Flatpak browser couldn't be reached and the launch silently no-op'd. browserLaunchEnv now rebuilds XDG_RUNTIME_DIR from /run/user/ when stripped, then derives WAYLAND_DISPLAY (wayland-N socket) and DBUS_SESSION_BUS_ADDRESS (/bus) from it. Existing values are never overridden. Confirmed live: with the fix a browser tab opens from a fully-stripped env; without it, nothing. New test file oauth-browser-env-wayland.test.ts. Co-Authored-By: Claude --- packages/mcp/src/oauth.ts | 33 ++++++- .../unit/oauth-browser-env-wayland.test.ts | 87 +++++++++++++++++++ 2 files changed, 118 insertions(+), 2 deletions(-) create mode 100644 packages/mcp/test/unit/oauth-browser-env-wayland.test.ts diff --git a/packages/mcp/src/oauth.ts b/packages/mcp/src/oauth.ts index b1f97380..210226dd 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. 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 00000000..4a0476cd --- /dev/null +++ b/packages/mcp/test/unit/oauth-browser-env-wayland.test.ts @@ -0,0 +1,87 @@ +/** + * 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 } 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, + }; + + 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; + }); + + 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`); + } + }); + + 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"; + 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"); + }); +}); From b8cc3168789aa23138fe51216c0290ce3975bb85 Mon Sep 17 00:00:00 2001 From: ArtyETH06 Date: Mon, 22 Jun 2026 12:07:27 -0700 Subject: [PATCH 10/17] fix(mcp): reconstruct XAUTHORITY for OAuth browser-open on Wayland MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the XDG_RUNTIME_DIR/WAYLAND/DBUS reconstruction: the browser still failed to open from Claude Desktop's stripped child env because an X11/XWayland browser (Chrome/Brave/Electron) needs the X authority cookie to connect to the display. Without XAUTHORITY the X server rejects the client ("Authorization required... Missing X server") and the browser segfaults — xdg-open returns 0, but no tab opens. browserLaunchEnv now injects XAUTHORITY from the Mutter Xwayland cookie at /.mutter-Xwaylandauth.*, falling back to ~/.Xauthority. Existing value never overridden. Confirmed live: with XAUTHORITY the browser launches instead of segfaulting. Test coverage extended. Co-Authored-By: Claude --- packages/mcp/src/oauth.ts | 28 +++++++++++++++++++ .../unit/oauth-browser-env-wayland.test.ts | 12 +++++++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/packages/mcp/src/oauth.ts b/packages/mcp/src/oauth.ts index 210226dd..be35ae70 100644 --- a/packages/mcp/src/oauth.ts +++ b/packages/mcp/src/oauth.ts @@ -626,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/unit/oauth-browser-env-wayland.test.ts b/packages/mcp/test/unit/oauth-browser-env-wayland.test.ts index 4a0476cd..efcebc59 100644 --- a/packages/mcp/test/unit/oauth-browser-env-wayland.test.ts +++ b/packages/mcp/test/unit/oauth-browser-env-wayland.test.ts @@ -15,7 +15,7 @@ * New file (existing oauth-browser-open.test.ts is left untouched). */ import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { existsSync } from "node:fs"; +import { existsSync, readdirSync } from "node:fs"; import { browserLaunchEnv } from "../../src/oauth.js"; const isLinux = process.platform === "linux"; @@ -29,6 +29,7 @@ describe("browserLaunchEnv — Wayland/DBus reconstruction", () => { 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(() => { @@ -37,6 +38,7 @@ describe("browserLaunchEnv — Wayland/DBus reconstruction", () => { delete process.env.WAYLAND_DISPLAY; delete process.env.DBUS_SESSION_BUS_ADDRESS; delete process.env.DISPLAY; + delete process.env.XAUTHORITY; }); afterEach(() => { @@ -68,6 +70,12 @@ describe("browserLaunchEnv — Wayland/DBus reconstruction", () => { 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)", () => { @@ -78,10 +86,12 @@ describe("browserLaunchEnv — Wayland/DBus reconstruction", () => { 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"); }); }); From e54bed627e142d69fea8ff942e80aa378360afe3 Mon Sep 17 00:00:00 2001 From: ArtyETH06 Date: Mon, 22 Jun 2026 13:12:08 -0700 Subject: [PATCH 11/17] chore(mcp): bump 0.23.0 -> 0.23.1 so .dxt reinstalls as a real update Claude Desktop won't replace an installed extension with the same version number, so the XAUTHORITY browser-open fix didn't take until the version changed. Bumps package.json + server.json. Co-Authored-By: Claude --- packages/mcp/package.json | 2 +- packages/mcp/server.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 22cf351c..4c394b6a 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 a12007b2..ee8bd74c 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" }, From 0ca76c3032a89cec16aa8173484217f9cd089994 Mon Sep 17 00:00:00 2001 From: ArtyETH06 Date: Mon, 22 Jun 2026 14:39:05 -0700 Subject: [PATCH 12/17] fix(core): sanitize custom-field config per type (500 on update/create) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit leadbay_update_custom_field and leadbay_create_custom_field sent the config object verbatim. The backend's per-type config models are strict (PriceFieldConfig = {currency}, Date/DateTime = {format}, ExternalId = {urlTemplate}, TEXT/NUMBER = none), so any extra key — a stale `format` left from the previous type on a type CHANGE, both url_template + urlTemplate, or a currency on a non-PRICE field — triggers a 500 "JSON deserialization error". Reproduced live on staging: TEXT→PRICE with an over-broad config 500s. Add sanitizeConfigForType (new _custom-field-config.ts) that narrows config to exactly the key(s) the target type accepts, and apply it in both tools before building the request body. Live-verified on staging: TEXT→PRICE/EUR now sends {currency:"EUR"} and succeeds; full create→update→delete lifecycle clean. Co-Authored-By: Claude --- .../core/src/tools/_custom-field-config.ts | 37 ++++++++++++++ .../core/src/tools/create-custom-field.ts | 10 +++- .../core/src/tools/update-custom-field.ts | 12 ++++- .../unit/tools/custom-field-config.test.ts | 48 +++++++++++++++++++ 4 files changed, 103 insertions(+), 4 deletions(-) create mode 100644 packages/core/src/tools/_custom-field-config.ts create mode 100644 packages/core/test/unit/tools/custom-field-config.test.ts 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 00000000..9bb105e9 --- /dev/null +++ b/packages/core/src/tools/_custom-field-config.ts @@ -0,0 +1,37 @@ +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, + config: CustomCrmFieldConfig | null | undefined +): CustomCrmFieldConfig | null { + if (!config) return null; + 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 356e672b..b8495d02 100644 --- a/packages/core/src/tools/create-custom-field.ts +++ b/packages/core/src/tools/create-custom-field.ts @@ -7,6 +7,7 @@ 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; @@ -85,10 +86,10 @@ export const createCustomField: Tool = { } const type = params.type ?? "TEXT"; - const config = params.config ?? null; + const rawConfig = params.config ?? null; if (type === "EXTERNAL_ID") { - const urlTemplate = config?.url_template ?? config?.urlTemplate; + const urlTemplate = rawConfig?.url_template ?? rawConfig?.urlTemplate; if (!urlTemplate || !urlTemplate.includes("{value}")) { throw client.makeError( "CUSTOM_FIELD_EXTERNAL_ID_TEMPLATE_REQUIRED", @@ -114,6 +115,11 @@ export const createCustomField: Tool = { } } + // Narrow config to exactly the key(s) the type accepts — the backend + // deserializer rejects extra keys (e.g. urlTemplate camelCase, or a + // currency on a non-PRICE field) with a 500. + const config = sanitizeConfigForType(type, rawConfig); + const body = { name, type, diff --git a/packages/core/src/tools/update-custom-field.ts b/packages/core/src/tools/update-custom-field.ts index c4b27039..86c1db41 100644 --- a/packages/core/src/tools/update-custom-field.ts +++ b/packages/core/src/tools/update-custom-field.ts @@ -7,6 +7,7 @@ import type { } 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; @@ -117,11 +118,11 @@ export const updateCustomField: Tool = { ); } const type = params.type !== undefined ? params.type : current.type; - const config = + const rawConfig = params.config !== undefined ? params.config : (current.config ?? null); if (type === "EXTERNAL_ID") { - const urlTemplate = config?.url_template ?? config?.urlTemplate; + const urlTemplate = rawConfig?.url_template ?? rawConfig?.urlTemplate; if (!urlTemplate || !urlTemplate.includes("{value}")) { throw client.makeError( "CUSTOM_FIELD_EXTERNAL_ID_TEMPLATE_REQUIRED", @@ -132,6 +133,13 @@ export const updateCustomField: Tool = { } } + // Narrow config to exactly the key(s) the target type accepts. The backend + // deserializer is strict — extra keys (e.g. a stale `format` left from the + // previous type, or both url_template + urlTemplate) cause a 500. This is + // critical on a type CHANGE, where `current.config` may carry keys the new + // type rejects. + const config = sanitizeConfigForType(type, rawConfig); + const body = { name, type, 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 00000000..6134e2df --- /dev/null +++ b/packages/core/test/unit/tools/custom-field-config.test.ts @@ -0,0 +1,48 @@ +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(); + }); +}); From 1bad426a7fad5c4f4748ea8651af7ca76a17552b Mon Sep 17 00:00:00 2001 From: ArtyETH06 Date: Mon, 22 Jun 2026 14:54:39 -0700 Subject: [PATCH 13/17] fix(core): tolerate stringified custom-field config (PRICE currency dropped) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent passes config as a JSON STRING (e.g. config:'{"currency":"EUR"}'), not an object. sanitizeConfigForType only read object properties, so a string config yielded no currency → backend 400 "PRICE requires a currency config" on a TEXT→PRICE update even though the rename landed. Observed live on staging. sanitizeConfigForType now JSON-parses a string config before narrowing, and both tools sanitize BEFORE the EXTERNAL_ID url_template check (so that check sees the parsed value too). Live-verified: the exact stringified payload the agent sent now narrows to {currency:"EUR"}. Co-Authored-By: Claude --- .../core/src/tools/_custom-field-config.ts | 20 +++++++++++++++++-- .../core/src/tools/create-custom-field.ts | 13 ++++++------ .../core/src/tools/update-custom-field.ts | 17 ++++++++-------- .../unit/tools/custom-field-config.test.ts | 15 ++++++++++++++ 4 files changed, 48 insertions(+), 17 deletions(-) diff --git a/packages/core/src/tools/_custom-field-config.ts b/packages/core/src/tools/_custom-field-config.ts index 9bb105e9..6ad5efa0 100644 --- a/packages/core/src/tools/_custom-field-config.ts +++ b/packages/core/src/tools/_custom-field-config.ts @@ -13,9 +13,25 @@ import type { CustomCrmFieldConfig, CustomCrmFieldKind } from "../types.js"; // or unknown), which the caller omits from the request body. export function sanitizeConfigForType( type: CustomCrmFieldKind, - config: CustomCrmFieldConfig | null | undefined + rawConfig: CustomCrmFieldConfig | string | null | undefined ): CustomCrmFieldConfig | null { - if (!config) return 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. diff --git a/packages/core/src/tools/create-custom-field.ts b/packages/core/src/tools/create-custom-field.ts index b8495d02..7b2a5a47 100644 --- a/packages/core/src/tools/create-custom-field.ts +++ b/packages/core/src/tools/create-custom-field.ts @@ -86,10 +86,14 @@ export const createCustomField: Tool = { } const type = params.type ?? "TEXT"; - const rawConfig = 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 = rawConfig?.url_template ?? rawConfig?.urlTemplate; + const urlTemplate = config?.url_template; if (!urlTemplate || !urlTemplate.includes("{value}")) { throw client.makeError( "CUSTOM_FIELD_EXTERNAL_ID_TEMPLATE_REQUIRED", @@ -115,11 +119,6 @@ export const createCustomField: Tool = { } } - // Narrow config to exactly the key(s) the type accepts — the backend - // deserializer rejects extra keys (e.g. urlTemplate camelCase, or a - // currency on a non-PRICE field) with a 500. - const config = sanitizeConfigForType(type, rawConfig); - const body = { name, type, diff --git a/packages/core/src/tools/update-custom-field.ts b/packages/core/src/tools/update-custom-field.ts index 86c1db41..2ff86939 100644 --- a/packages/core/src/tools/update-custom-field.ts +++ b/packages/core/src/tools/update-custom-field.ts @@ -121,8 +121,16 @@ export const updateCustomField: Tool = { 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 = rawConfig?.url_template ?? rawConfig?.urlTemplate; + const urlTemplate = config?.url_template; if (!urlTemplate || !urlTemplate.includes("{value}")) { throw client.makeError( "CUSTOM_FIELD_EXTERNAL_ID_TEMPLATE_REQUIRED", @@ -133,13 +141,6 @@ export const updateCustomField: Tool = { } } - // Narrow config to exactly the key(s) the target type accepts. The backend - // deserializer is strict — extra keys (e.g. a stale `format` left from the - // previous type, or both url_template + urlTemplate) cause a 500. This is - // critical on a type CHANGE, where `current.config` may carry keys the new - // type rejects. - const config = sanitizeConfigForType(type, rawConfig); - const body = { name, type, diff --git a/packages/core/test/unit/tools/custom-field-config.test.ts b/packages/core/test/unit/tools/custom-field-config.test.ts index 6134e2df..9c921b5f 100644 --- a/packages/core/test/unit/tools/custom-field-config.test.ts +++ b/packages/core/test/unit/tools/custom-field-config.test.ts @@ -45,4 +45,19 @@ describe("sanitizeConfigForType", () => { 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(); + }); }); From eaef6e37b7d756e9e13d4026624955eaf859da0f Mon Sep 17 00:00:00 2001 From: ArtyETH06 Date: Mon, 22 Jun 2026 15:14:20 -0700 Subject: [PATCH 14/17] fix(core): address PR review on qualification + custom-field tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2 — set_qualification_methods: gate confirm on the ACTUAL removed questions, not on count. A remove+add or `set` that keeps the count the same still drops a scoring question; the old `next.length < previousCount` check let that through without confirm. Now computes the removed set and requires confirm whenever any existing question would be dropped. P2 — get_qualification_methods: fetch ai_agent_questions DIRECTLY instead of via resolveTasteProfile (Promise.allSettled substitutes [] on a rejected fetch). A transient backend/auth failure now surfaces as an error rather than a false "no questions configured" that could lead a caller to overwrite real questions. P3 — create/update_custom_field: the tools parse a stringified config, so the input schema now advertises it (type: ["object","string","null"]) and the param types accept string — making the recovery path valid for schema- validating clients. New tests: same-count swap confirm gate, and fetch-failure-surfaces. Co-Authored-By: Claude --- .../composite/get-qualification-methods.ts | 22 +++--- .../composite/set-qualification-methods.ts | 12 ++-- .../core/src/tools/create-custom-field.ts | 7 +- .../core/src/tools/update-custom-field.ts | 8 ++- .../get-qualification-methods-error.test.ts | 52 ++++++++++++++ ...qualification-methods-swap-confirm.test.ts | 67 +++++++++++++++++++ 6 files changed, 150 insertions(+), 18 deletions(-) create mode 100644 packages/core/test/unit/composite/get-qualification-methods-error.test.ts create mode 100644 packages/core/test/unit/composite/set-qualification-methods-swap-confirm.test.ts diff --git a/packages/core/src/composite/get-qualification-methods.ts b/packages/core/src/composite/get-qualification-methods.ts index 2f4a540f..a813f3c1 100644 --- a/packages/core/src/composite/get-qualification-methods.ts +++ b/packages/core/src/composite/get-qualification-methods.ts @@ -1,5 +1,5 @@ import type { LeadbayClient } from "../client.js"; -import type { Tool, ToolContext } from "../types.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"; @@ -57,15 +57,21 @@ export const getQualificationMethods: Tool> = { _params: Record, ctx?: ToolContext ) => { - // resolveMe FIRST so its /users/me result is cached before - // resolveTasteProfile (which resolves the org id from the same /me) and - // before withAgentMemoryMeta reuse it — avoids a concurrent double-fetch. - // Both are best-effort for the role flag. + // 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 profile = await client.resolveTasteProfile(); - - const questions = profile.qualificationQuestions ?? []; 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) { diff --git a/packages/core/src/composite/set-qualification-methods.ts b/packages/core/src/composite/set-qualification-methods.ts index 4763a50e..81ecdf32 100644 --- a/packages/core/src/composite/set-qualification-methods.ts +++ b/packages/core/src/composite/set-qualification-methods.ts @@ -184,9 +184,13 @@ export const setQualificationMethods: Tool = { ); } - // Shrinking the list is destructive — require confirm. - if (next.length < previousCount && params.confirm !== true) { - const removed = currentQs.filter((q) => !next.some((n) => norm(n) === norm(q))); + // 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, { @@ -195,7 +199,7 @@ export const setQualificationMethods: Tool = { previous_count: previousCount, changed: false, region: client.region, - hint: `Re-call with confirm:true to apply. This would remove ${previousCount - next.length} question(s): ${removed + 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.`, }, diff --git a/packages/core/src/tools/create-custom-field.ts b/packages/core/src/tools/create-custom-field.ts index 7b2a5a47..580e4712 100644 --- a/packages/core/src/tools/create-custom-field.ts +++ b/packages/core/src/tools/create-custom-field.ts @@ -11,7 +11,8 @@ 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; } @@ -40,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", diff --git a/packages/core/src/tools/update-custom-field.ts b/packages/core/src/tools/update-custom-field.ts index 2ff86939..e6fcbdd3 100644 --- a/packages/core/src/tools/update-custom-field.ts +++ b/packages/core/src/tools/update-custom-field.ts @@ -13,7 +13,9 @@ interface UpdateCustomFieldParams { id: string; name?: string; type?: CustomCrmFieldKind; - config?: CustomCrmFieldConfig | null; + // 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 + @@ -51,9 +53,9 @@ export const updateCustomField: Tool = { "New type: TEXT, NUMBER, PRICE, DATE, DATETIME, or EXTERNAL_ID. Omit to keep the current type.", }, config: { - type: ["object", "null"], + type: ["object", "string", "null"], description: - "New type-specific config. EXTERNAL_ID requires {url_template:'https://.../{value}'}; PRICE requires {currency:'USD'}; DATE/DATETIME may set {format}. Omit to keep current config.", + "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"], 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 00000000..9681e8be --- /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/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 00000000..85d7e44f --- /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); + }); +}); From b0070201db8d78ac935d2c50334de3c211e331b4 Mon Sep 17 00:00:00 2001 From: ArtyETH06 Date: Mon, 22 Jun 2026 15:20:39 -0700 Subject: [PATCH 15/17] docs(mcp): add 0.23.1 CHANGELOG entry (review P3) The package ships CHANGELOG.md but it started at 0.23.0, so consumers installing 0.23.1 had no release notes for the new qualification-method + custom-field tools. Adds the 0.23.1 entry. Co-Authored-By: Claude --- packages/mcp/CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/mcp/CHANGELOG.md b/packages/mcp/CHANGELOG.md index e68101f6..5771f8a8 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. From 2ff9c2a23ce585816eb36e5ab2419d6989572de4 Mon Sep 17 00:00:00 2001 From: ArtyETH06 Date: Tue, 23 Jun 2026 14:30:18 -0700 Subject: [PATCH 16/17] =?UTF-8?q?refactor(core):=20rename=20qualification?= =?UTF-8?q?=5Fmethods=20=E2=86=92=20questions=20+=20move=20custom-field=20?= =?UTF-8?q?tools=20to=20composite/=20(review)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Milan's PR review: 1. Naming — "qualification methods" introduced a new term for what the UI calls "qualification questions". Renamed both tools + all wording, files, templates, WORKFLOWS rows, tests, CHANGELOG: leadbay_get_qualification_methods → leadbay_get_qualification_questions leadbay_set_qualification_methods → leadbay_set_qualification_questions 2. Classification — "simple getters/setters are considered composites". Moved create/update/delete_custom_field (+ the _custom-field-config helper) from src/tools/ into src/composite/, and added them to COMPOSITE_FILE_TOOL_NAMES (so they carry the _triggered_by provenance mandate like other composites). No behavior change; all gates green (core 463, mcp 466). Co-Authored-By: Claude --- WORKFLOWS.md | 16 ++++----- .../src/composite/_composite-file-names.ts | 7 ++-- .../_custom-field-config.ts | 0 .../create-custom-field.ts | 0 .../delete-custom-field.ts | 0 ...hods.ts => get-qualification-questions.ts} | 20 +++++------ ...hods.ts => set-qualification-questions.ts} | 24 ++++++------- .../update-custom-field.ts | 0 packages/core/src/index.ts | 22 ++++++------ .../core/src/tool-descriptions.generated.ts | 36 +++++++++---------- .../create-custom-field.test.ts | 2 +- .../custom-field-config.test.ts | 2 +- .../delete-custom-field.test.ts | 2 +- ...get-qualification-questions-error.test.ts} | 10 +++--- ...ts => get-qualification-questions.test.ts} | 14 ++++---- ...lification-questions-swap-confirm.test.ts} | 12 +++---- ...ts => set-qualification-questions.test.ts} | 20 +++++------ .../update-custom-field.test.ts | 2 +- packages/mcp/CHANGELOG.md | 6 ++-- packages/mcp/test/audit/routing-block.test.ts | 2 +- .../test/output-schema-conformance.test.ts | 4 +-- ...pl => get-qualification-questions.md.tmpl} | 22 ++++++------ ...pl => set-qualification-questions.md.tmpl} | 10 +++--- 23 files changed, 118 insertions(+), 115 deletions(-) rename packages/core/src/{tools => composite}/_custom-field-config.ts (100%) rename packages/core/src/{tools => composite}/create-custom-field.ts (100%) rename packages/core/src/{tools => composite}/delete-custom-field.ts (100%) rename packages/core/src/composite/{get-qualification-methods.ts => get-qualification-questions.ts} (80%) rename packages/core/src/composite/{set-qualification-methods.ts => set-qualification-questions.ts} (91%) rename packages/core/src/{tools => composite}/update-custom-field.ts (100%) rename packages/core/test/unit/{tools => composite}/create-custom-field.test.ts (97%) rename packages/core/test/unit/{tools => composite}/custom-field-config.test.ts (96%) rename packages/core/test/unit/{tools => composite}/delete-custom-field.test.ts (96%) rename packages/core/test/unit/composite/{get-qualification-methods-error.test.ts => get-qualification-questions-error.test.ts} (80%) rename packages/core/test/unit/composite/{get-qualification-methods.test.ts => get-qualification-questions.test.ts} (85%) rename packages/core/test/unit/composite/{set-qualification-methods-swap-confirm.test.ts => set-qualification-questions-swap-confirm.test.ts} (80%) rename packages/core/test/unit/composite/{set-qualification-methods.test.ts => set-qualification-questions.test.ts} (79%) rename packages/core/test/unit/{tools => composite}/update-custom-field.test.ts (97%) rename packages/promptforge/tool-descriptions/composite/{get-qualification-methods.md.tmpl => get-qualification-questions.md.tmpl} (79%) rename packages/promptforge/tool-descriptions/composite/{set-qualification-methods.md.tmpl => set-qualification-questions.md.tmpl} (71%) diff --git a/WORKFLOWS.md b/WORKFLOWS.md index 85c6e4f9..f481c173 100644 --- a/WORKFLOWS.md +++ b/WORKFLOWS.md @@ -46,9 +46,9 @@ 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?" | +| 35 | **Org qualification questions** — "what qualification questions does Leadbay use", "how are my leads qualified" — retrieve the org-level AI-agent question catalog | `leadbay_get_qualification_questions` | "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." | +| 37 | **Modify qualification questions** — "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_questions` | "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." | --- @@ -511,15 +511,15 @@ prompt: "Create a group for menuisiers, pergolas, vérandas" ``` ```yaml expected -workflow_name: Org qualification methods +workflow_name: Org qualification questions prompt_name: ~ required_calls: - - leadbay_get_qualification_methods + - leadbay_get_qualification_questions forbidden_calls: - leadbay_research_lead_by_id - leadbay_get_taste_profile success_criteria: - - "called leadbay_get_qualification_methods at least once" + - "called leadbay_get_qualification_questions 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)" @@ -547,14 +547,14 @@ prompt: "Pull one of my leads and show me its CRM custom field values." ``` ```yaml expected -workflow_name: Modify qualification methods +workflow_name: Modify qualification questions prompt_name: ~ required_calls: - - leadbay_set_qualification_methods + - leadbay_set_qualification_questions 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" + - "removed the named question via leadbay_set_qualification_questions (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" diff --git a/packages/core/src/composite/_composite-file-names.ts b/packages/core/src/composite/_composite-file-names.ts index 61f1acfb..4a87d54b 100644 --- a/packages/core/src/composite/_composite-file-names.ts +++ b/packages/core/src/composite/_composite-file-names.ts @@ -19,11 +19,13 @@ export const COMPOSITE_FILE_TOOL_NAMES: ReadonlySet = new Set([ "leadbay_campaign_call_sheet", "leadbay_campaign_progression", "leadbay_create_campaign", + "leadbay_create_custom_field", + "leadbay_delete_custom_field", "leadbay_enrich_titles", "leadbay_extend_lens", "leadbay_followups_map", "leadbay_get_lead_custom_fields", - "leadbay_get_qualification_methods", + "leadbay_get_qualification_questions", "leadbay_import_and_qualify", "leadbay_import_leads", "leadbay_import_status", @@ -44,7 +46,8 @@ 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_set_qualification_questions", "leadbay_team_activity", + "leadbay_update_custom_field", "leadbay_tour_plan", ]); diff --git a/packages/core/src/tools/_custom-field-config.ts b/packages/core/src/composite/_custom-field-config.ts similarity index 100% rename from packages/core/src/tools/_custom-field-config.ts rename to packages/core/src/composite/_custom-field-config.ts diff --git a/packages/core/src/tools/create-custom-field.ts b/packages/core/src/composite/create-custom-field.ts similarity index 100% rename from packages/core/src/tools/create-custom-field.ts rename to packages/core/src/composite/create-custom-field.ts diff --git a/packages/core/src/tools/delete-custom-field.ts b/packages/core/src/composite/delete-custom-field.ts similarity index 100% rename from packages/core/src/tools/delete-custom-field.ts rename to packages/core/src/composite/delete-custom-field.ts diff --git a/packages/core/src/composite/get-qualification-methods.ts b/packages/core/src/composite/get-qualification-questions.ts similarity index 80% rename from packages/core/src/composite/get-qualification-methods.ts rename to packages/core/src/composite/get-qualification-questions.ts index a813f3c1..90f50238 100644 --- a/packages/core/src/composite/get-qualification-methods.ts +++ b/packages/core/src/composite/get-qualification-questions.ts @@ -2,23 +2,23 @@ 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"; +import { leadbay_get_qualification_questions as GET_QUALIFICATION_QUESTIONS_DESCRIPTION } from "../tool-descriptions.generated.js"; -// Org-level "qualification methods" = the AI-agent questions Leadbay scores +// Org-level "qualification questions" = 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 +// use leadbay_set_qualification_questions (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", +export const getQualificationQuestions: Tool> = { + name: "leadbay_get_qualification_questions", annotations: { - title: "Read the org's qualification methods", + title: "Read the org's qualification questions", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true, }, - description: GET_QUALIFICATION_METHODS_DESCRIPTION, + description: GET_QUALIFICATION_QUESTIONS_DESCRIPTION, inputSchema: { type: "object", properties: {}, @@ -40,7 +40,7 @@ export const getQualificationMethods: Tool> = { 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.", + "Whether the current bearer-token holder is an org admin. Admins can modify the questions via leadbay_set_qualification_questions.", }, region: { type: "string" }, hint: { @@ -76,10 +76,10 @@ export const getQualificationMethods: Tool> = { 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."; + "No qualification questions configured yet. Use leadbay_set_qualification_questions 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."; + "You're an org admin — use leadbay_set_qualification_questions to add, remove, or replace these questions."; } return withAgentMemoryMeta( diff --git a/packages/core/src/composite/set-qualification-methods.ts b/packages/core/src/composite/set-qualification-questions.ts similarity index 91% rename from packages/core/src/composite/set-qualification-methods.ts rename to packages/core/src/composite/set-qualification-questions.ts index 81ecdf32..98c8e555 100644 --- a/packages/core/src/composite/set-qualification-methods.ts +++ b/packages/core/src/composite/set-qualification-questions.ts @@ -2,9 +2,9 @@ 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"; +import { leadbay_set_qualification_questions as SET_QUALIFICATION_QUESTIONS_DESCRIPTION } from "../tool-descriptions.generated.js"; -interface SetQualificationMethodsParams { +interface SetQualificationQuestionsParams { // Full replacement list. Mutually exclusive with add/remove. questions?: string[]; // Append these (deduped against current). Mutually exclusive with `questions`. @@ -16,23 +16,23 @@ interface SetQualificationMethodsParams { confirm?: boolean; } -// Modify the org's qualification methods (the AI-agent questions every lead is +// Modify the org's qualification questions (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", +export const setQualificationQuestions: Tool = { + name: "leadbay_set_qualification_questions", annotations: { - title: "Modify the org's qualification methods", + title: "Modify the org's qualification questions", readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true, }, - description: SET_QUALIFICATION_METHODS_DESCRIPTION, + description: SET_QUALIFICATION_QUESTIONS_DESCRIPTION, write: true, inputSchema: { type: "object", @@ -88,7 +88,7 @@ export const setQualificationMethods: Tool = { }, execute: async ( client: LeadbayClient, - params: SetQualificationMethodsParams, + params: SetQualificationQuestionsParams, ctx?: ToolContext ) => { const hasSet = Array.isArray(params.questions); @@ -97,7 +97,7 @@ export const setQualificationMethods: Tool = { if (hasSet && (hasAdd || hasRemove)) { throw client.makeError( - "QUALIFICATION_METHODS_BAD_ARGS", + "QUALIFICATION_QUESTIONS_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}" @@ -105,7 +105,7 @@ export const setQualificationMethods: Tool = { } if (!hasSet && !hasAdd && !hasRemove) { throw client.makeError( - "QUALIFICATION_METHODS_NO_CHANGE", + "QUALIFICATION_QUESTIONS_NO_CHANGE", "nothing to change — pass `questions`, `add`, or `remove`", "Provide a full `questions` list, or `add`/`remove` entries.", "POST /organizations/{orgId}" @@ -157,7 +157,7 @@ export const setQualificationMethods: Tool = { const MAX_QUESTIONS = 5; if (next.length > MAX_QUESTIONS) { throw client.makeError( - "QUALIFICATION_METHODS_LIMIT", + "QUALIFICATION_QUESTIONS_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}" @@ -178,7 +178,7 @@ export const setQualificationMethods: Tool = { 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.", + hint: "No change — the resulting list is identical to the current one. Pass different `add`/`remove` entries, or call leadbay_get_qualification_questions to review the current questions.", }, ctx ); diff --git a/packages/core/src/tools/update-custom-field.ts b/packages/core/src/composite/update-custom-field.ts similarity index 100% rename from packages/core/src/tools/update-custom-field.ts rename to packages/core/src/composite/update-custom-field.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6f28ef59..b5c98418 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -69,9 +69,9 @@ import { setPushback } from "./tools/set-pushback.js"; 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 { createCustomField } from "./composite/create-custom-field.js"; +import { updateCustomField } from "./composite/update-custom-field.js"; +import { deleteCustomField } from "./composite/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 @@ -100,8 +100,8 @@ 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 { getQualificationQuestions } from "./composite/get-qualification-questions.js"; +import { setQualificationQuestions } from "./composite/set-qualification-questions.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"; @@ -171,8 +171,8 @@ export { // new composite reads pullLeads, pullFollowups, followupsMap, tourPlan, listCampaigns, campaignProgression, campaignCallSheet, researchLeadById, researchLeadByNameFuzzy, - getQualificationMethods, getLeadCustomFields, - setQualificationMethods, + getQualificationQuestions, getLeadCustomFields, + setQualificationQuestions, accountHistory, recallOrderedTitles, accountStatus, scanPortfolioSignals, teamActivity, bulkEnrichStatus, qualifyStatus, importStatus, resolveImportRows, @@ -270,11 +270,11 @@ export const compositeReadTools: Tool[] = [ campaignCallSheet, researchLeadById, researchLeadByNameFuzzy, - // Org qualification methods — the AI-agent questions every lead is scored + // Org qualification questions — 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, + getQualificationQuestions, // 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). @@ -380,10 +380,10 @@ export const compositeWriteTools: Tool[] = [ // LEADBAY_MCP_WRITE=1 in MCP. updateCustomField, deleteCustomField, - // Modify the org's qualification methods (AI-agent questions). Full-replace + // Modify the org's qualification questions (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, + setQualificationQuestions, // 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 caa3c53f..58ab789a 100644 --- a/packages/core/src/tool-descriptions.generated.ts +++ b/packages/core/src/tool-descriptions.generated.ts @@ -1486,10 +1486,10 @@ 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 +// region: leadbay_get_qualification_questions +export const leadbay_get_qualification_questions: 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". +Trigger phrases: "what are my qualification questions", "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. @@ -1499,7 +1499,7 @@ Prefer when: user wants the ORG-level qualification questions catalog, no lead a Examples that SHOULD invoke this tool: - "What qualification questions does Leadbay use to score my leads?" -- "Show me my org's qualification methods." +- "Show me my org's qualification questions." Examples that should NOT invoke this tool (sound similar, route elsewhere): - "How did Acme Corp answer the qualification questions?" @@ -1509,12 +1509,12 @@ Examples that should NOT invoke this tool (sound similar, route elsewhere): 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, +leadbay_set_qualification_questions for editing). When the list is empty, render the \`hint\` instead. --- -Retrieve the organization's **qualification methods** — the AI-agent questions +Retrieve the organization's **qualification questions** — 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\`. @@ -1525,17 +1525,17 @@ Returns: 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 + questions (\`leadbay_set_qualification_questions\`) 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 +**leadbay_set_qualification_questions** (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; +Companion tools: **leadbay_set_qualification_questions** 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 @@ -1544,14 +1544,14 @@ 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 +the order returned. Lead with a short heading like **"Qualification questions (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 — +leadbay_set_qualification_questions). Do not invent questions or reword them — render verbatim. `; -// endregion: leadbay_get_qualification_methods +// endregion: leadbay_get_qualification_questions // 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\`. @@ -3853,8 +3853,8 @@ 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". +// region: leadbay_set_qualification_questions +export const leadbay_set_qualification_questions: string = `Modify the organization's **qualification questions** — 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: @@ -3870,13 +3870,13 @@ Returns the resulting \`{qualification_questions, count, previous_count, changed 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. +WHEN NOT TO USE: to READ the questions (use leadbay_get_qualification_questions) 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 +// endregion: leadbay_set_qualification_questions // 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. @@ -4203,7 +4203,7 @@ export const TOOL_DESCRIPTIONS = { leadbay_get_lens_filter, leadbay_get_lens_scoring, leadbay_get_prospecting_actions, - leadbay_get_qualification_methods, + leadbay_get_qualification_questions, leadbay_get_quota, leadbay_get_selection_ids, leadbay_get_taste_profile, @@ -4250,7 +4250,7 @@ export const TOOL_DESCRIPTIONS = { leadbay_set_active_lens, leadbay_set_epilogue_status, leadbay_set_pushback, - leadbay_set_qualification_methods, + leadbay_set_qualification_questions, leadbay_set_user_prompt, leadbay_team_activity, leadbay_tour_plan, diff --git a/packages/core/test/unit/tools/create-custom-field.test.ts b/packages/core/test/unit/composite/create-custom-field.test.ts similarity index 97% rename from packages/core/test/unit/tools/create-custom-field.test.ts rename to packages/core/test/unit/composite/create-custom-field.test.ts index c1624471..631952e9 100644 --- a/packages/core/test/unit/tools/create-custom-field.test.ts +++ b/packages/core/test/unit/composite/create-custom-field.test.ts @@ -9,7 +9,7 @@ import { vi.mock("node:https", () => httpsMockFactory()); import { LeadbayClient } from "../../../src/client.js"; -import { createCustomField } from "../../../src/tools/create-custom-field.js"; +import { createCustomField } from "../../../src/composite/create-custom-field.js"; const BASE = "https://api-us.leadbay.app"; diff --git a/packages/core/test/unit/tools/custom-field-config.test.ts b/packages/core/test/unit/composite/custom-field-config.test.ts similarity index 96% rename from packages/core/test/unit/tools/custom-field-config.test.ts rename to packages/core/test/unit/composite/custom-field-config.test.ts index 9c921b5f..509ecfd7 100644 --- a/packages/core/test/unit/tools/custom-field-config.test.ts +++ b/packages/core/test/unit/composite/custom-field-config.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { sanitizeConfigForType } from "../../../src/tools/_custom-field-config.js"; +import { sanitizeConfigForType } from "../../../src/composite/_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 diff --git a/packages/core/test/unit/tools/delete-custom-field.test.ts b/packages/core/test/unit/composite/delete-custom-field.test.ts similarity index 96% rename from packages/core/test/unit/tools/delete-custom-field.test.ts rename to packages/core/test/unit/composite/delete-custom-field.test.ts index 7cb09bd4..a9d3ea1d 100644 --- a/packages/core/test/unit/tools/delete-custom-field.test.ts +++ b/packages/core/test/unit/composite/delete-custom-field.test.ts @@ -3,7 +3,7 @@ import { mockHttp, resetHttpMock, getHttpRequests, httpsMockFactory } from "../. vi.mock("node:https", () => httpsMockFactory()); import { LeadbayClient } from "../../../src/client.js"; -import { deleteCustomField } from "../../../src/tools/delete-custom-field.js"; +import { deleteCustomField } from "../../../src/composite/delete-custom-field.js"; const BASE = "https://api-us.leadbay.app"; const newClient = () => new LeadbayClient(BASE, "u.test-token", "us"); diff --git a/packages/core/test/unit/composite/get-qualification-methods-error.test.ts b/packages/core/test/unit/composite/get-qualification-questions-error.test.ts similarity index 80% rename from packages/core/test/unit/composite/get-qualification-methods-error.test.ts rename to packages/core/test/unit/composite/get-qualification-questions-error.test.ts index 9681e8be..b5e41526 100644 --- a/packages/core/test/unit/composite/get-qualification-methods-error.test.ts +++ b/packages/core/test/unit/composite/get-qualification-questions-error.test.ts @@ -3,7 +3,7 @@ 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"; +import { getQualificationQuestions } from "../../../src/composite/get-qualification-questions.js"; const BASE = "https://api-us.leadbay.app"; const ORG = "org-1"; @@ -23,13 +23,13 @@ const me = () => ({ // 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", () => { +describe("leadbay_get_qualification_questions — 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(); + await expect(getQualificationQuestions.execute(newClient(), {})).rejects.toThrow(); }); it("ai_agent_questions 401 → throws (auth failure not shown as empty)", async () => { @@ -37,7 +37,7 @@ describe("leadbay_get_qualification_methods — fetch failure surfaces", () => { me(), { method: "GET", path: new RegExp(`/1\\.5/organizations/${ORG}/ai_agent_questions`), status: 401, body: {} }, ]); - await expect(getQualificationMethods.execute(newClient(), {})).rejects.toThrow(); + await expect(getQualificationQuestions.execute(newClient(), {})).rejects.toThrow(); }); it("genuine empty (200 + []) → returns empty with the no-questions hint", async () => { @@ -45,7 +45,7 @@ describe("leadbay_get_qualification_methods — fetch failure surfaces", () => { me(), { method: "GET", path: new RegExp(`/1\\.5/organizations/${ORG}/ai_agent_questions`), status: 200, body: [] }, ]); - const res: any = await getQualificationMethods.execute(newClient(), {}); + const res: any = await getQualificationQuestions.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-questions.test.ts similarity index 85% rename from packages/core/test/unit/composite/get-qualification-methods.test.ts rename to packages/core/test/unit/composite/get-qualification-questions.test.ts index 075b301e..ad93054d 100644 --- a/packages/core/test/unit/composite/get-qualification-methods.test.ts +++ b/packages/core/test/unit/composite/get-qualification-questions.test.ts @@ -3,7 +3,7 @@ import { mockHttp, resetHttpMock, getHttpRequests, httpsMockFactory } from "../. vi.mock("node:https", () => httpsMockFactory()); import { LeadbayClient } from "../../../src/client.js"; -import { getQualificationMethods } from "../../../src/composite/get-qualification-methods.js"; +import { getQualificationQuestions } from "../../../src/composite/get-qualification-questions.js"; const BASE = "https://api-us.leadbay.app"; const ORG = "org-1"; @@ -36,10 +36,10 @@ function mockTaste(questions: unknown[]) { ]; } -describe("leadbay_get_qualification_methods", () => { +describe("leadbay_get_qualification_questions", () => { 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(), {}); + const res: any = await getQualificationQuestions.execute(newClient(), {}); expect(res.qualification_questions).toHaveLength(2); expect(res.qualification_questions[0]).toEqual({ @@ -58,15 +58,15 @@ describe("leadbay_get_qualification_methods", () => { it("admin user — surfaces is_admin + points at the modify tool", async () => { mockHttp([mockMe(true), ...mockTaste(QUESTIONS)]); - const res: any = await getQualificationMethods.execute(newClient(), {}); + const res: any = await getQualificationQuestions.execute(newClient(), {}); expect(res.is_admin).toBe(true); - expect(res.hint).toMatch(/leadbay_set_qualification_methods/); + expect(res.hint).toMatch(/leadbay_set_qualification_questions/); }); it("empty catalog — empty array + empty-state hint", async () => { mockHttp([mockMe(false), ...mockTaste([])]); - const res: any = await getQualificationMethods.execute(newClient(), {}); + const res: any = await getQualificationQuestions.execute(newClient(), {}); expect(res.qualification_questions).toEqual([]); expect(res.count).toBe(0); @@ -75,7 +75,7 @@ describe("leadbay_get_qualification_methods", () => { it("does not POST anything (pure read)", async () => { mockHttp([mockMe(false), ...mockTaste(QUESTIONS)]); - await getQualificationMethods.execute(newClient(), {}); + await getQualificationQuestions.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-questions-swap-confirm.test.ts similarity index 80% rename from packages/core/test/unit/composite/set-qualification-methods-swap-confirm.test.ts rename to packages/core/test/unit/composite/set-qualification-questions-swap-confirm.test.ts index 85d7e44f..e99c9552 100644 --- a/packages/core/test/unit/composite/set-qualification-methods-swap-confirm.test.ts +++ b/packages/core/test/unit/composite/set-qualification-questions-swap-confirm.test.ts @@ -3,7 +3,7 @@ import { mockHttp, resetHttpMock, getHttpRequests, httpsMockFactory } from "../. vi.mock("node:https", () => httpsMockFactory()); import { LeadbayClient } from "../../../src/client.js"; -import { setQualificationMethods } from "../../../src/composite/set-qualification-methods.js"; +import { setQualificationQuestions } from "../../../src/composite/set-qualification-questions.js"; const BASE = "https://api-us.leadbay.app"; const ORG = "org-1"; @@ -32,10 +32,10 @@ const didPost = () => getHttpRequests().some((r) => r.method === "POST" && new R // 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", () => { +describe("leadbay_set_qualification_questions — 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] }); + const res: any = await setQualificationQuestions.execute(newClient(), { remove: [Q1], add: [Q3] }); expect(res.changed).toBe(false); expect(res.hint).toMatch(/confirm:true/); expect(res.hint).toContain(Q1); @@ -44,7 +44,7 @@ describe("leadbay_set_qualification_methods — same-count swap needs confirm", 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 }); + const res: any = await setQualificationQuestions.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); @@ -52,7 +52,7 @@ describe("leadbay_set_qualification_methods — same-count swap needs confirm", 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] }); + const res: any = await setQualificationQuestions.execute(newClient(), { questions: [Q1, Q3] }); expect(res.changed).toBe(false); expect(res.hint).toContain(Q2); // Q2 is the dropped one expect(didPost()).toBe(false); @@ -60,7 +60,7 @@ describe("leadbay_set_qualification_methods — same-count swap needs confirm", it("pure add (no removal) still needs NO confirm", async () => { mockHttp([me(), current([Q1]), postOrg()]); - const res: any = await setQualificationMethods.execute(newClient(), { add: [Q2] }); + const res: any = await setQualificationQuestions.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-questions.test.ts similarity index 79% rename from packages/core/test/unit/composite/set-qualification-methods.test.ts rename to packages/core/test/unit/composite/set-qualification-questions.test.ts index faaac4f2..ace7c3c7 100644 --- a/packages/core/test/unit/composite/set-qualification-methods.test.ts +++ b/packages/core/test/unit/composite/set-qualification-questions.test.ts @@ -3,7 +3,7 @@ import { mockHttp, resetHttpMock, getHttpRequests, httpsMockFactory } from "../. vi.mock("node:https", () => httpsMockFactory()); import { LeadbayClient } from "../../../src/client.js"; -import { setQualificationMethods } from "../../../src/composite/set-qualification-methods.js"; +import { setQualificationQuestions } from "../../../src/composite/set-qualification-questions.js"; const BASE = "https://api-us.leadbay.app"; const ORG = "org-1"; @@ -40,11 +40,11 @@ const postBody = () => { return p ? JSON.parse(p.body ?? "{}") : null; }; -describe("leadbay_set_qualification_methods", () => { +describe("leadbay_set_qualification_questions", () => { 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] }); + const res: any = await setQualificationQuestions.execute(newClient(), { add: [Q2] }); expect(res.changed).toBe(true); expect(res.count).toBe(2); @@ -57,7 +57,7 @@ describe("leadbay_set_qualification_methods", () => { it("add duplicate — no-op, does not POST", async () => { mockHttp([me(), currentQuestions([Q1])]); - const res: any = await setQualificationMethods.execute(newClient(), { add: [Q1] }); + const res: any = await setQualificationQuestions.execute(newClient(), { add: [Q1] }); expect(res.changed).toBe(false); expect(res.hint).toMatch(/No change/i); @@ -67,7 +67,7 @@ describe("leadbay_set_qualification_methods", () => { it("remove without confirm — previews, does NOT post", async () => { mockHttp([me(), currentQuestions([Q1, Q2])]); - const res: any = await setQualificationMethods.execute(newClient(), { remove: [Q2] }); + const res: any = await setQualificationQuestions.execute(newClient(), { remove: [Q2] }); expect(res.changed).toBe(false); expect(res.hint).toMatch(/confirm:true/); @@ -78,7 +78,7 @@ describe("leadbay_set_qualification_methods", () => { 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 }); + const res: any = await setQualificationQuestions.execute(newClient(), { remove: [Q2], confirm: true }); expect(res.changed).toBe(true); expect(res.count).toBe(1); @@ -88,7 +88,7 @@ describe("leadbay_set_qualification_methods", () => { 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] }); + const res: any = await setQualificationQuestions.execute(newClient(), { questions: [Q1, Q2] }); expect(res.changed).toBe(true); expect(res.count).toBe(2); @@ -98,21 +98,21 @@ describe("leadbay_set_qualification_methods", () => { it("questions + add together — rejected (mutually exclusive)", async () => { mockHttp([]); await expect( - setQualificationMethods.execute(newClient(), { questions: [Q1], add: [Q2] }) + setQualificationQuestions.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(); + await expect(setQualificationQuestions.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"] }) + setQualificationQuestions.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/update-custom-field.test.ts b/packages/core/test/unit/composite/update-custom-field.test.ts similarity index 97% rename from packages/core/test/unit/tools/update-custom-field.test.ts rename to packages/core/test/unit/composite/update-custom-field.test.ts index 0b6cd3dd..97bb9999 100644 --- a/packages/core/test/unit/tools/update-custom-field.test.ts +++ b/packages/core/test/unit/composite/update-custom-field.test.ts @@ -3,7 +3,7 @@ import { mockHttp, resetHttpMock, getHttpRequests, httpsMockFactory } from "../. vi.mock("node:https", () => httpsMockFactory()); import { LeadbayClient } from "../../../src/client.js"; -import { updateCustomField } from "../../../src/tools/update-custom-field.js"; +import { updateCustomField } from "../../../src/composite/update-custom-field.js"; const BASE = "https://api-us.leadbay.app"; const newClient = () => new LeadbayClient(BASE, "u.test-token", "us"); diff --git a/packages/mcp/CHANGELOG.md b/packages/mcp/CHANGELOG.md index 5771f8a8..72b6b5c4 100644 --- a/packages/mcp/CHANGELOG.md +++ b/packages/mcp/CHANGELOG.md @@ -2,10 +2,10 @@ ## 0.23.1 — 2026-06-22 -Retrieve + modify qualification methods and CRM custom fields over MCP (product#3768). +Retrieve + modify qualification questions 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_qualification_questions`** (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_questions`** (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. diff --git a/packages/mcp/test/audit/routing-block.test.ts b/packages/mcp/test/audit/routing-block.test.ts index 92579770..25317ce8 100644 --- a/packages/mcp/test/audit/routing-block.test.ts +++ b/packages/mcp/test/audit/routing-block.test.ts @@ -56,7 +56,7 @@ const TOOLS_WITH_ROUTING = new Set([ "leadbay_new_lens", "leadbay_adjust_audience", "leadbay_refine_prompt", - "leadbay_get_qualification_methods", + "leadbay_get_qualification_questions", "leadbay_get_lead_custom_fields", "leadbay_add_contact", "leadbay_remove_contact", diff --git a/packages/mcp/test/output-schema-conformance.test.ts b/packages/mcp/test/output-schema-conformance.test.ts index 26d8f399..0807815d 100644 --- a/packages/mcp/test/output-schema-conformance.test.ts +++ b/packages/mcp/test/output-schema-conformance.test.ts @@ -162,7 +162,7 @@ interface ConformanceCase { const CASES: ConformanceCase[] = [ { - toolName: "leadbay_set_qualification_methods", + toolName: "leadbay_set_qualification_questions", arguments: { add: ["Is the company hiring installers?"] }, setupMocks: () => { mockHttp([ @@ -228,7 +228,7 @@ const CASES: ConformanceCase[] = [ }, }, { - toolName: "leadbay_get_qualification_methods", + toolName: "leadbay_get_qualification_questions", arguments: {}, setupMocks: () => { mockHttp([ diff --git a/packages/promptforge/tool-descriptions/composite/get-qualification-methods.md.tmpl b/packages/promptforge/tool-descriptions/composite/get-qualification-questions.md.tmpl similarity index 79% rename from packages/promptforge/tool-descriptions/composite/get-qualification-methods.md.tmpl rename to packages/promptforge/tool-descriptions/composite/get-qualification-questions.md.tmpl index cb94d877..cc34e480 100644 --- a/packages/promptforge/tool-descriptions/composite/get-qualification-methods.md.tmpl +++ b/packages/promptforge/tool-descriptions/composite/get-qualification-questions.md.tmpl @@ -1,15 +1,15 @@ --- -name: leadbay_get_qualification_methods +name: leadbay_get_qualification_questions kind: tool-description short_description: | - Retrieve the org's qualification methods — the AI-agent questions Leadbay + Retrieve the org's qualification questions — 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 are my qualification questions" - "what questions does Leadbay ask about each lead" - "show me the org qualification questions" - "how are my leads being qualified" @@ -23,14 +23,14 @@ routing: examples: positive: - "What qualification questions does Leadbay use to score my leads?" - - "Show me my org's qualification methods." + - "Show me my org's qualification questions." 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, + leadbay_set_qualification_questions for editing). When the list is empty, render the `hint` instead. annotations: readOnlyHint: true @@ -38,7 +38,7 @@ annotations: idempotentHint: true openWorldHint: true --- -Retrieve the organization's **qualification methods** — the AI-agent questions +Retrieve the organization's **qualification questions** — 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`. @@ -49,17 +49,17 @@ Returns: 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 + questions (`leadbay_set_qualification_questions`) 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 +**leadbay_set_qualification_questions** (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; +Companion tools: **leadbay_set_qualification_questions** 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 @@ -68,9 +68,9 @@ 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 +the order returned. Lead with a short heading like **"Qualification questions (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 — +leadbay_set_qualification_questions). 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-questions.md.tmpl similarity index 71% rename from packages/promptforge/tool-descriptions/composite/set-qualification-methods.md.tmpl rename to packages/promptforge/tool-descriptions/composite/set-qualification-questions.md.tmpl index ca31fd2a..15e71768 100644 --- a/packages/promptforge/tool-descriptions/composite/set-qualification-methods.md.tmpl +++ b/packages/promptforge/tool-descriptions/composite/set-qualification-questions.md.tmpl @@ -1,17 +1,17 @@ --- -name: leadbay_set_qualification_methods +name: leadbay_set_qualification_questions kind: tool-description short_description: | - Modify the org's qualification methods — the AI-agent questions every lead is + Modify the org's qualification questions — 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. + confirm:true. Read them first with leadbay_get_qualification_questions. 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". +Modify the organization's **qualification questions** — 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: @@ -27,7 +27,7 @@ Returns the resulting `{qualification_questions, count, previous_count, changed} {{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. +{{include:headers/tool-when-not-to-use}} to READ the questions (use leadbay_get_qualification_questions) or to change a single lead's data. This is org-level — it affects scoring for ALL leads. ### RENDERING From e93ec4b59a3cac199629e48ae2c67e4b7405d6bd Mon Sep 17 00:00:00 2001 From: ArtyETH06 Date: Tue, 23 Jun 2026 14:38:03 -0700 Subject: [PATCH 17/17] docs(core): align confirm schema with same-count swaps (review P3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The set_qualification_questions schema + description said confirm:true was required only when the list gets SHORTER, but the code (correctly) gates on ANY dropped question — so an agent following the contract would make a same-count swap without confirm and get a non-changing preview instead of the edit. Reworded the `confirm` schema description, the tool body, and the rendering note to state that dropping any existing question (incl. a same-count swap or a `questions` replacement omitting a current question) needs confirm; pure additions never do. Behavior unchanged; docs now match it. Co-Authored-By: Claude --- packages/core/src/composite/set-qualification-questions.ts | 2 +- packages/core/src/tool-descriptions.generated.ts | 4 ++-- .../composite/set-qualification-questions.md.tmpl | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/core/src/composite/set-qualification-questions.ts b/packages/core/src/composite/set-qualification-questions.ts index 98c8e555..fbeb8d74 100644 --- a/packages/core/src/composite/set-qualification-questions.ts +++ b/packages/core/src/composite/set-qualification-questions.ts @@ -58,7 +58,7 @@ export const setQualificationQuestions: Tool = 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.", + "Required whenever the change DROPS ANY existing question — including a same-count swap or a `questions` replacement that omits a current question, not only when the list gets shorter (removing a question changes how every lead is scored). Without it, such a change is previewed and not applied. Pure additions never need confirm.", }, }, additionalProperties: false, diff --git a/packages/core/src/tool-descriptions.generated.ts b/packages/core/src/tool-descriptions.generated.ts index 58ab789a..bca24293 100644 --- a/packages/core/src/tool-descriptions.generated.ts +++ b/packages/core/src/tool-descriptions.generated.ts @@ -3864,7 +3864,7 @@ The backend stores the list as a whole, so this tool reads the current questions 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. +**Dropping any existing question is destructive** — it changes how every lead is scored. Any change that removes a current question requires \`confirm:true\` — including a same-count **swap** (remove one + add one) or a \`questions\` replacement that omits a current question, not only when the list gets shorter. Without \`confirm\`, the tool previews what would be removed and applies nothing. Pure additions never 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 …?"). @@ -3874,7 +3874,7 @@ WHEN NOT TO USE: to READ the questions (use leadbay_get_qualification_questions) ### 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. +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. When the result is a non-changing preview (a removal awaiting confirmation), surface the \`hint\` (what would be removed) and ask the user to confirm — do NOT auto-confirm. `; // endregion: leadbay_set_qualification_questions diff --git a/packages/promptforge/tool-descriptions/composite/set-qualification-questions.md.tmpl b/packages/promptforge/tool-descriptions/composite/set-qualification-questions.md.tmpl index 15e71768..46ff1113 100644 --- a/packages/promptforge/tool-descriptions/composite/set-qualification-questions.md.tmpl +++ b/packages/promptforge/tool-descriptions/composite/set-qualification-questions.md.tmpl @@ -21,7 +21,7 @@ The backend stores the list as a whole, so this tool reads the current questions 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. +**Dropping any existing question is destructive** — it changes how every lead is scored. Any change that removes a current question requires `confirm:true` — including a same-count **swap** (remove one + add one) or a `questions` replacement that omits a current question, not only when the list gets shorter. Without `confirm`, the tool previews what would be removed and applies nothing. Pure additions never 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 …?"). @@ -31,4 +31,4 @@ Returns the resulting `{qualification_questions, count, previous_count, changed} ### 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. +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. When the result is a non-changing preview (a removal awaiting confirmation), surface the `hint` (what would be removed) and ask the user to confirm — do NOT auto-confirm.