Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
d1efce9
feat(core): retrieve org qualification methods + per-lead custom fields
ArtyETH06 Jun 19, 2026
3b4254c
test(eval): add eval contracts for qualification-methods + lead-custo…
ArtyETH06 Jun 19, 2026
1f8cd44
feat(core): update + delete CRM custom fields
ArtyETH06 Jun 19, 2026
8c0ad30
feat(core): modify org qualification methods (write)
ArtyETH06 Jun 19, 2026
2bcb871
test(eval): add eval contract for modify-qualification-methods (wf 32)
ArtyETH06 Jun 19, 2026
7b1e519
test(eval): add eval contract for modify-custom-fields (wf 33)
ArtyETH06 Jun 19, 2026
37deedf
fix(core): point get_qualification_methods at the modify tool
ArtyETH06 Jun 22, 2026
6784436
test(eval): make wf32 a self-restoring round-trip
ArtyETH06 Jun 22, 2026
a8e4c36
Merge remote-tracking branch 'origin/main' into ArtyETH06/mcp-export-…
ArtyETH06 Jun 22, 2026
c847b82
fix(mcp): reconstruct XDG_RUNTIME_DIR + DBUS for OAuth browser-open o…
ArtyETH06 Jun 22, 2026
b8cc316
fix(mcp): reconstruct XAUTHORITY for OAuth browser-open on Wayland
ArtyETH06 Jun 22, 2026
e54bed6
chore(mcp): bump 0.23.0 -> 0.23.1 so .dxt reinstalls as a real update
ArtyETH06 Jun 22, 2026
0ca76c3
fix(core): sanitize custom-field config per type (500 on update/create)
ArtyETH06 Jun 22, 2026
1bad426
fix(core): tolerate stringified custom-field config (PRICE currency d…
ArtyETH06 Jun 22, 2026
eaef6e3
fix(core): address PR review on qualification + custom-field tools
ArtyETH06 Jun 22, 2026
b007020
docs(mcp): add 0.23.1 CHANGELOG entry (review P3)
ArtyETH06 Jun 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions WORKFLOWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ The table is the human-readable index. The `yaml expected` + `yaml scenario` blo
| 32 | **Build an interactive artifact** — "build me a call sheet / interactive lead board with a campaign dropdown, notes, statuses, likes per lead" — the agent fetches headless view-models + usage guide via `leadbay_artifact_kit`, then assembles a single-file HTML artifact whose `lb.field`/`lb.action` view-models POPULATE a dropdown from `leadbay_list_campaigns` and submit `leadbay_report_outreach` / `leadbay_add_leads_to_campaign` / `leadbay_like_lead` (carrying `verification` + `_triggered_by` where required); the artifact owns all rendering | `leadbay_artifact_kit` *(no dedicated prompt)* | "Build me an interactive call sheet for these leads." |
| 33 | **Manager team-activity view** — "how is my team doing", "top performers this month", "activity by rep" — `leadbay_team_activity` returns a per-rep leaderboard (`reps`, sorted by `total_activities`) + an activity time-series (`trend`) for a look-back window, the data behind the web Dashboard-Manager screen. Feeds a manager artifact (`lb.teamActivity` → table + Chart.js); quota/remaining stays on `leadbay_account_status` | `leadbay_team_activity` *(no dedicated prompt)* | "How is my team doing this month?" |
| 34 | **Campaign builder from scratch (solo)** — "build me a campaign from scratch" — one guided flow: discover on the active lens → qualify/pick a cohort → enrich the BUYER PERSONA of the user's product (revenue org, not seniority) with a coverage guarantee → persist via `leadbay_create_campaign` → render the ready-to-work `leadbay_campaign_call_sheet` view, then hand off to `leadbay_work_campaign`. Distinct from the team flow (`leadbay_setup_team_prospecting`) and the work-an-existing-one flow (`leadbay_work_campaign`). | `leadbay_build_campaign` | *(multi-turn — see `turns:` contract)* |
| 35 | **Org qualification methods** — "what qualification questions does Leadbay use", "how are my leads qualified" — retrieve the org-level AI-agent question catalog | `leadbay_get_qualification_methods` | "What qualification questions does Leadbay use to score my leads?" |
| 36 | **Per-lead custom-field values** — "what custom fields are on this lead", "show the CRM custom field values for <Company>" — retrieve the custom-field VALUES stored on one lead (distinct from the definitions catalog in `leadbay_list_mappable_fields`) | `leadbay_get_lead_custom_fields` | "What custom field values are stored on this lead?" |
| 37 | **Modify qualification methods** — "add a qualification question", "remove the X question", "change my qualification questions" — write the org's AI-agent questions. Enforces the max-5 cap and gates removals behind a confirm; does not invent or silently drop questions | `leadbay_set_qualification_methods` | "Remove the qualification question 'hghg', then add it back exactly as it was." |
| 38 | **Modify custom fields** — "create a custom field", "rename the X field", "delete the Y field" — manage the org CRM custom-field catalog. Update renames/retypes in place; delete is destructive and gated behind a confirm | `leadbay_create_custom_field`, `leadbay_update_custom_field`, `leadbay_delete_custom_field` | "Create a custom field called 'Eval Probe Field', then rename it to 'Eval Probe Renamed', then delete it." |

---

Expand Down Expand Up @@ -506,6 +510,82 @@ success_criteria:
prompt: "Create a group for menuisiers, pergolas, vérandas"
```

```yaml expected
workflow_name: Org qualification methods
prompt_name: ~
required_calls:
- leadbay_get_qualification_methods
forbidden_calls:
- leadbay_research_lead_by_id
- leadbay_get_taste_profile
success_criteria:
- "called leadbay_get_qualification_methods at least once"
- "listed the org's qualification questions returned by the tool, verbatim (did not invent or reword them)"
- "did NOT fabricate a per-lead score or answer — these are org-level questions, not a single lead's responses"
- "did NOT call leadbay_research_lead_by_id or leadbay_get_taste_profile (this is the focused org-level questions tool)"
```

```yaml scenario
prompt: "What qualification questions does Leadbay use to score my leads?"
```

```yaml expected
workflow_name: Per-lead custom-field values
prompt_name: ~
required_calls:
- leadbay_get_lead_custom_fields
forbidden_calls:
- leadbay_list_mappable_fields
success_criteria:
- "called leadbay_get_lead_custom_fields with a lead id (discovering a lead first if needed)"
- "reported the lead's custom-field VALUES from the tool result — or, when the result is empty, correctly stated the lead/org has no custom-field values set (did not invent fields or values)"
- "did NOT call leadbay_list_mappable_fields — that returns field DEFINITIONS (the catalog), not a lead's values"
```

```yaml scenario
prompt: "Pull one of my leads and show me its CRM custom field values."
```

```yaml expected
workflow_name: Modify qualification methods
prompt_name: ~
required_calls:
- leadbay_set_qualification_methods
forbidden_calls:
- leadbay_create_custom_field
success_criteria:
- "removed the named question via leadbay_set_qualification_methods (remove mode) and then re-added it — a round-trip that nets back to the original set"
- "honored the confirm gate on the removal (re-called with confirm:true after the safety preview, since removing shrinks the list) rather than ignoring it"
- "reported each step truthfully from the tool result (removed N→N-1, re-added N-1→N) without inventing a change the tool did not return"
- "only touched the single named question; did NOT drop or rewrite the OTHER questions, and did NOT call leadbay_create_custom_field"
# Self-restoring by construction: the scenario removes a question then adds the
# SAME text back, so the org's question set is identical before and after. The
# eval harness ALSO snapshots + restores the questions around the run as a
# backstop. Never leaves the live org mutated.
```

```yaml scenario
prompt: "Remove the qualification question 'hghg', then add it back exactly as it was."
```

```yaml expected
workflow_name: Modify custom fields
prompt_name: ~
required_calls:
- leadbay_create_custom_field
- leadbay_update_custom_field
- leadbay_delete_custom_field
success_criteria:
- "created the field, then renamed it via leadbay_update_custom_field, then deleted it via leadbay_delete_custom_field — using the field id returned by create, not a guessed id"
- "the final delete actually completed (passed confirm:true, or confirmed after the safety preview) so the throwaway field does not linger"
- "reported each step truthfully from tool results (created / renamed / deleted) without inventing ids or claiming a change the tool did not return"
- "did NOT touch or delete any OTHER custom field — only the one it just created"
```

```yaml scenario
prompt: "Create a custom field called 'Eval Probe Field', then rename it to 'Eval Probe Renamed', then delete it."
```

#### Workflow 30 — Account status: silent on unreadable quota

```yaml expected
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/composite/_composite-file-names.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export const COMPOSITE_FILE_TOOL_NAMES: ReadonlySet<string> = 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",
Expand All @@ -42,6 +44,7 @@ export const COMPOSITE_FILE_TOOL_NAMES: ReadonlySet<string> = new Set([
"leadbay_resolve_import_rows",
"leadbay_scan_portfolio_signals",
"leadbay_seed_candidates",
"leadbay_set_qualification_methods",
"leadbay_team_activity",
"leadbay_tour_plan",
]);
158 changes: 158 additions & 0 deletions packages/core/src/composite/get-lead-custom-fields.ts
Original file line number Diff line number Diff line change
@@ -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<GetLeadCustomFieldsParams> = {
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<void>("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<LeadPayload>(
"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<CustomFieldDef[]>(
"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<string, CustomFieldDef>(
(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.<id>).";
} 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
);
},
};
101 changes: 101 additions & 0 deletions packages/core/src/composite/get-qualification-methods.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import type { LeadbayClient } from "../client.js";
import type { Tool, ToolContext, AiAgentQuestionPayload } from "../types.js";
import { withAgentMemoryMeta } from "../agent-memory/index.js";

import { leadbay_get_qualification_methods as GET_QUALIFICATION_METHODS_DESCRIPTION } from "../tool-descriptions.generated.js";

// Org-level "qualification methods" = the AI-agent questions Leadbay scores
// every lead against. Focused read tool: returns ONLY the question catalog
// (not the broader taste profile). Read-only itself; to MODIFY the questions
// use leadbay_set_qualification_methods (org-admin only, which every user is
// for their own org). For admins we surface that pointer in the hint.
export const getQualificationMethods: Tool<Record<string, never>> = {
name: "leadbay_get_qualification_methods",
annotations: {
title: "Read the org's qualification methods",
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
},
description: GET_QUALIFICATION_METHODS_DESCRIPTION,
inputSchema: {
type: "object",
properties: {},
additionalProperties: false,
},
outputSchema: {
type: "object",
properties: {
qualification_questions: {
type: "array",
description:
"Org-level questions Leadbay scores every lead against. Each: {question, created_at, lang}.",
items: { type: "object" },
},
count: {
type: "number",
description: "Number of qualification questions configured.",
},
is_admin: {
type: "boolean",
description:
"Whether the current bearer-token holder is an org admin. Admins can modify the questions via leadbay_set_qualification_methods.",
},
region: { type: "string" },
hint: {
type: "string",
description:
"Operator note — admin edit pointer, or the empty-state message when no questions are configured.",
},
_meta: { type: "object" },
},
required: ["qualification_questions"],
},
execute: async (
client: LeadbayClient,
_params: Record<string, never>,
ctx?: ToolContext
) => {
// resolveMe FIRST so its /users/me result is cached + gives us the org id.
// The role flag is best-effort (null on failure → not admin).
const me = await client.resolveMe().catch(() => null);
const isAdmin = me?.admin ?? false;
const orgId = me?.organization?.id ?? (await client.resolveOrgId());

// Fetch the questions endpoint DIRECTLY (not via resolveTasteProfile, which
// uses Promise.allSettled and substitutes [] for a rejected fetch). A
// transient backend/auth failure must surface as an ERROR here — never as a
// false "no questions configured", which could lead a caller to overwrite
// an org's real questions.
const questions = await client.request<AiAgentQuestionPayload[]>(
"GET",
`/organizations/${orgId}/ai_agent_questions`
) ?? [];

let hint: string | undefined;
if (questions.length === 0) {
hint =
"No qualification questions configured yet. Use leadbay_set_qualification_methods to add some, or leadbay_refine_prompt to shape the AI agent.";
} else if (isAdmin) {
hint =
"You're an org admin — use leadbay_set_qualification_methods to add, remove, or replace these questions.";
}

return withAgentMemoryMeta(
client,
{
qualification_questions: questions.map((q) => ({
question: q.question,
created_at: q.created_at,
lang: q.lang,
})),
count: questions.length,
is_admin: isAdmin,
region: client.region,
...(hint ? { hint } : {}),
},
ctx
);
},
};
Loading
Loading