@leadbay/mcp 0.39.4 → 0.39.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1531,7 +1531,7 @@ Split the returned \`monitor_leads\` into two buckets client-side using their en
1531
1531
 
1532
1532
  \`discover_leads\` are the **New** bucket.
1533
1533
 
1534
- Aim for a 3+3+3 split if possible. If the customers bucket has fewer than 3, fill from qualified. If discover_filter_note indicates a low match ratio for the city, mention it: "Only N/30 fresh leads matched your city" \u2014 better honest than padded.
1534
+ Aim for a 3+3+3 split if possible. If the customers bucket has fewer than 3, fill from qualified. If discover_filter_note indicates a low match ratio for the city, mention it: "Only N/30 fresh leads matched your city" \u2014 better honest than padded. When it splits the stops into the ones in the city and the ones within \`radius_km\` of it, repeat that split and name the town each nearby stop is in \u2014 a company in Courbevoie is a stop on a Paris day, but it is not in Paris. If I named a radius ("within 10 km", "rayon de 20km"), pass it as \`radius_km\`.
1535
1535
 
1536
1536
  # PHASE 3 \u2014 PRESENT THE ITINERARY + OFFER THE MAP
1537
1537
 
@@ -2110,10 +2110,12 @@ When the response carries \`social_urls\` (the post-fix multi-platform URL block
2110
2110
 
2111
2111
 
2112
2112
  # PHASE 2 \u2014 NOT FOUND
2113
- If the resolver returns \`LEAD_NOT_FOUND\`, read its hint: it names the field
2114
- that would have found the company (\`would_help\`, usually \`website\`). **Ask the
2115
- user for that field first** \u2014 "what's their website?" \u2014 and call the tool again
2116
- with it. Only when they cannot supply it should you say both their leads and
2113
+ If the resolver returns \`resolution: "not_found"\`, that call SUCCEEDED \u2014 it is
2114
+ the answer, not a failure. Do what its \`next_step\` says. Usually that is to ask
2115
+ for the field \`would_help\` names (normally \`website\`): **ask the user for it
2116
+ first** \u2014 "what's their website?" \u2014 and call the tool again with it. When
2117
+ \`would_help\` is empty the search was scoped to one lens, and \`next_step\` says
2118
+ to drop the scope rather than to ask the user for anything. Only when they cannot supply it should you say both their leads and
2117
2119
  the Leadbay registry were searched.
2118
2120
  **Do NOT call \`leadbay_import_and_qualify\` automatically.**
2119
2121
  Offer to import and qualify as a separate, explicit next step; only call it
@@ -4843,6 +4845,8 @@ to pick, then re-call with the id/exact name.
4843
4845
 
4844
4846
  Restrict (or expand) the lens audience by sector / size. Free-text sectors are auto-resolved against the sector taxonomy; ambiguous matches are surfaced to the agent rather than guessed silently. Permission routing is hidden: the default lens auto-clones to a new user lens; an org-level lens defaults to a per-user draft (admins can override with \`save_for_org:true\`). Filter MERGES with existing criteria (unrelated criteria are not dropped).
4845
4847
 
4848
+ **A complaint about relevance is not a filter request.** "The leads aren't relevant", "they don't look like my customers", "I keep getting the wrong companies" says the OUTPUT is wrong \u2014 it does not say which sector to add. Read \`leadbay_get_qualification_questions\` first (it returns the questions, the ideal buyer profile AND the targeting prompt), and find out WHICH leads were wrong, ideally by pulling some and pointing at them. Sector and size are only one of the places the answer can live; editing them blind fixes the wrong thing and leaves the real cause in place.
4849
+
4846
4850
  **Targeting a lens \u2014 READ THIS.** By default this edits the user's ACTIVE lens. **If the user names a lens** ("add fintech to my **Joinery** lens", "in my Nordics lens, exclude retail"), you MUST pass \`lensName\` with that name (\`lensName:"Joinery"\`). Do NOT silently edit the active lens when a different one was named \u2014 that corrupts the wrong audience and is a top friction source. The name resolves against the user's lenses (case-insensitive, exact then unique-substring); it is edit-only and does NOT change which lens is active. An unmatched name returns \`status:"lens_not_found"\` with the lens list, and a name matching several returns \`status:"ambiguous_lens"\` with the candidates \u2014 surface them and re-call with the exact \`lensName\` or a \`lensId\`. Use \`leadbay_my_lenses\` if the user first wants to SEE or SWITCH lenses. To CREATE a brand-new lens, use \`leadbay_new_lens\` \u2014 not this tool.
4847
4851
 
4848
4852
  **Geography \u2014 scope a sales territory.** Pass \`locations\` (free text like \`["Indre-et-Loire"]\`, \`["Texas"]\`, \`["Austin"]\`, or admin-area ids) to restrict the lens to a region, and \`exclude_locations\` to carve one out. Free text auto-resolves via \`/geo/search\` at any level from state down to city \u2014 state, *r\xE9gion*, *d\xE9partement*, county, city. Unresolved/ambiguous text returns \`status:"ambiguous_locations"\` with candidates \u2014 surface them and re-call the chosen id via the SAME axis it came from: an INCLUDE pick \u2192 \`location_ids\`; an EXCLUDE pick \u2192 \`exclude_locations\` (**NOT** \`location_ids\`, which would include the area the user asked to exclude). The returned \`message\` names the right param per text. This is how a director scopes a rep's territory and then asks for net-new accounts there.
@@ -5714,7 +5718,7 @@ Do NOT use for: "show me today's leads / what's new today" \u2192 \`leadbay_pull
5714
5718
  Prefer when: the user describes a target profile or names a count of NEW companies \u2014 craft the example_lead per the seed rules below BEFORE calling; never pass the user's raw sentence as query.
5715
5719
 
5716
5720
  Examples that SHOULD invoke this tool:
5717
- - "Find me 10 gyms around Dallas that would buy our flooring, with someone I can call."
5721
+ - "Find me 10 gyms around Dallas that would buy our flooring, with a contact."
5718
5722
  - "Get me 20 new US SaaS companies, 50-2000 employees, with the VP People's email."
5719
5723
  - "We're launching in Lyon \u2014 find 15 hotels that fit our ICP."
5720
5724
 
@@ -5725,11 +5729,10 @@ Examples that should NOT invoke this tool (sound similar, route elsewhere):
5725
5729
 
5726
5730
  ## RENDER (quick)
5727
5731
 
5728
- 3-col table of delivered leads in returned order: col 1 = 10-segment fit
5729
- bar + linked company \xB7 location \xB7 size; col 2 = why-fits \u226420 words; col 3
5730
- = contact + found channels. ALWAYS close with the honest funnel line
5731
- (matched/examined/delivered/stop reason) \u2014 especially on 0
5732
- delivered. Full algorithm below.
5732
+ 3-col table of delivered leads in returned order: col 1 = 10-segment fit bar
5733
+ + linked company \xB7 location \xB7 size; col 2 = why-fits \u226420 words; col 3 =
5734
+ contact + found channels. ALWAYS close with the honest funnel line
5735
+ (matched/examined/delivered/stop reason), especially on 0 delivered.
5733
5736
 
5734
5737
  ---
5735
5738
 
@@ -5738,27 +5741,25 @@ company universe, applies hard filters, skips what the org already knows
5738
5741
  (\`novelty: org\`), optionally qualifies against the org's own intelligence
5739
5742
  (questions, tags, ideal buyer profile \u2014 frozen at submit), and optionally reveals
5740
5743
  contact channels. Polls up to \`wait_seconds\` (default 45); a longer job returns
5741
- \`still_running\` + \`next_poll\` \u2014 hand off to \`leadbay_lead_job_status\`. Jobs run
5744
+ \`still_running\` + \`next_poll\` \u2014 hand to \`leadbay_lead_job_status\`. Jobs run
5742
5745
  \u226430 min, results kept 30 days.
5743
5746
 
5744
5747
  **Free vs usage quota \u2014 never use quota silently.** Default (\`qualify: false\`,
5745
5748
  \`channels: []\`) is FREE: company profile + fit score + cached research +
5746
5749
  contact identity. \`qualify: true\` (per candidate EXAMINED, capped by
5747
5750
  \`exploration_cap\`/\`max_cost\`) and \`channels\` (only when a value is found) draw
5748
- on the org's usage quota; nothing is invoiced. Enforced in code: such a call is WITHHELD unless it
5749
- carries \`confirm: true\` \u2014 nothing is submitted and you get
5751
+ on the org's usage quota; nothing is invoiced. Enforced in code: such a call is
5752
+ WITHHELD unless it carries \`confirm: true\` \u2014 nothing is submitted and you get
5750
5753
  \`mode: "needs_confirmation"\` with a real quote to show the user. Re-call with
5751
5754
  \`confirm: true\` on their go-ahead ("go ahead / get their emails" counts).
5752
- \`confirm: false\` vetoes. Free needs no consent. **Preview free first** \u2014
5753
- reshaping an off-profile seed is free, exploring it with \`qualify: true\` is
5754
- not.
5755
+ \`confirm: false\` vetoes. **Preview free first** \u2014 reshaping an off-profile seed
5756
+ is free, exploring it with \`qualify: true\` is not.
5755
5757
 
5756
5758
  **Ad-hoc exclusions ("no chains") are enforced by NO tier** \u2014 \`filters\` has no
5757
- exclusion key, and \`qualify\` scores against the org's FROZEN questions and IBP,
5758
- which need not mention chains; the seed's inverse only shifts ranking.
5759
- Violators can survive, use quota and be delivered \u2014 post-filter them yourself
5760
- and say the tier didn't enforce it. Durable enforcement \u2192
5761
- \`leadbay_refine_prompt\`.
5759
+ exclusion key, and \`qualify\` scores against the org's FROZEN questions and IBP.
5760
+ Violators survive, use quota and get delivered: post-filter them yourself and
5761
+ say the tier didn't enforce it. Durable enforcement \u2192
5762
+ \`leadbay_set_qualification_questions\` / \`leadbay_refine_prompt\`.
5762
5763
 
5763
5764
  ### Crafting the \`example_lead\` seed \u2014 the input that decides result quality
5764
5765
 
@@ -5778,13 +5779,13 @@ measured:
5778
5779
  model, what they sell or operate, who they serve, observable scale. Write
5779
5780
  it like the first paragraph of their About-Us page.
5780
5781
  - STRONG: "Operator of full-service fitness centers offering strength
5781
- areas, group classes and personal training to members across multiple
5782
- clubs."
5783
- - WEAK (generic): "A gym in Texas."
5784
- - WRONG (seller-side): "Supplier of durable modular flooring for gyms."
5785
- 4. **No event language.** "hiring", "expanding", "just raised" are not
5786
- filters \u2014 registry descriptions never contain them, so they dilute the
5787
- profile. Purchase triggers belong in the org's qualification questions.
5782
+ areas, group classes and personal training to members across clubs."
5783
+ - WEAK: "A gym in Texas." WRONG: "Supplier of gym flooring." (seller-side)
5784
+ 4. **NO event language \u2014 in \`description\` or \`query\`.** "recrute", "hiring",
5785
+ "expanding", "just raised" never appear in registry text, so they match
5786
+ nothing. Send the trigger to \`leadbay_set_qualification_questions\` or
5787
+ \`leadbay_refine_prompt\` and say so. "companies hiring a senior SDR" seeds as
5788
+ "B2B software company operating an in-house outbound sales team."
5788
5789
  5. **No meta-markers.** Never "(example)", "(fictional)", "(placeholder)".
5789
5790
  6. **Hard constraints go in \`filters\`, not prose \u2014 exact keys:**
5790
5791
  \`sectors: string[]\`, \`locations: string[]\`, \`employees_min: number\`,
@@ -5794,24 +5795,22 @@ measured:
5794
5795
  never a country: this workspace's own is dropped, any other is refused.
5795
5796
  7. **Prefer \`example_lead\` over \`query\`.** Query matches topic *vocabulary*:
5796
5797
  "gyms that need durable flooring" surfaced flooring VENDORS, 0 delivered.
5797
- Use \`query\` only for signal an example can't express.
5798
5798
  8. **One seed per buyer archetype.** An ask spanning two segments ("gyms and
5799
5799
  warehouses") needs one search each with its own description and
5800
- \`request_id\` \u2014 a blended seed lands between the clusters and matches
5801
- neither.
5800
+ \`request_id\` \u2014 a blended seed lands between the clusters, matching neither.
5802
5801
 
5803
5802
 
5804
5803
  **Parameter notes**
5805
5804
  - \`request_id\` (REQUIRED) is the retry contract: SAME value retrying the same
5806
5805
  ask (same live job, no double launch); NEW for a changed ask. Derive from ask
5807
5806
  + archetype + date: \`gyms-dallas-2026-07-28\`.
5808
- - Never lower \`min_ai_score\` together with \`channels\` \u2014 that reveals emails for
5809
- leads the AI just scored as junk.
5810
- - \`count\` \u2264 50; \u22643 active jobs/org; \u226410 submits/hour (429 + Retry-After \u2014
5811
- wait, don't hammer).
5807
+ - Never lower \`min_ai_score\` with \`channels\` \u2014 that reveals emails for leads
5808
+ the AI just scored as junk.
5809
+ - \`count\` \u2264 50; \u22643 active jobs/org; \u226410 submits/hour (429 + Retry-After \u2014 wait,
5810
+ don't hammer).
5812
5811
 
5813
5812
  **Read the result honestly** \u2014 \`funnel\` + \`explain.scope_notes\` tell the story;
5814
- zero delivered gets a cause and a next move (rules in RENDERING).
5813
+ zero delivered gets a cause and a next move (RENDERING).
5815
5814
 
5816
5815
  ---
5817
5816
 
@@ -6292,26 +6291,28 @@ WHEN NOT TO USE: when the lead summary's \`prospecting_actions_count\` is 0.
6292
6291
  `;
6293
6292
  var leadbay_get_qualification_questions = `## WHEN TO USE
6294
6293
 
6295
- 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".
6294
+ 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", "(before any settings change) what is configured today", "why am I getting these leads", "the leads aren't relevant \u2014 what are my settings".
6296
6295
 
6297
- Do NOT use for: "how did this lead score on the qualification questions" \u2192 \`leadbay_research_lead_by_id\`; "show my ideal buyer profile and intent tags" \u2192 \`leadbay_get_taste_profile\`.
6296
+ Do NOT use for: "how did this lead score on the qualification questions" \u2192 \`leadbay_research_lead_by_id\`; "change / add / remove a qualification question" \u2192 \`leadbay_set_qualification_questions\`; "answer a pending clarification" \u2192 \`leadbay_answer_clarification\`.
6298
6297
 
6299
- Prefer when: user wants the ORG-level qualification questions catalog, no lead and no buyer profile
6298
+ Prefer when: user wants the ORG-level qualification settings, or you are about to change any of them \u2014 read this FIRST to see whether their rule is already covered
6300
6299
 
6301
6300
  Examples that SHOULD invoke this tool:
6302
6301
  - "What qualification questions does Leadbay use to score my leads?"
6303
6302
  - "Show me my org's qualification questions."
6303
+ - "Why do I keep getting these companies? What are my settings?"
6304
6304
 
6305
6305
  Examples that should NOT invoke this tool (sound similar, route elsewhere):
6306
6306
  - "How did Acme Corp answer the qualification questions?"
6307
- - "What's my ideal buyer profile?"
6307
+ - "Add a question about install crews."
6308
6308
 
6309
6309
  ## RENDER (quick)
6310
6310
 
6311
- Numbered list of the questions (chat-native markdown), each one line. When
6312
- \`is_admin\` is true, append the \`hint\` as a footnote (points at
6313
- leadbay_set_qualification_questions for editing). When the list is empty,
6314
- render the \`hint\` instead.
6311
+ Numbered list of the questions (chat-native markdown), each one line,
6312
+ verbatim. Below it, the ideal buyer profile summary + anti-patterns and the
6313
+ targeting prompt when set. When \`is_admin\` is true, append the \`hint\` as a
6314
+ footnote (points at leadbay_set_qualification_questions). When the question
6315
+ list is empty, say so explicitly and render the \`hint\` instead.
6315
6316
 
6316
6317
  ---
6317
6318
 
@@ -6324,7 +6325,15 @@ Returns:
6324
6325
 
6325
6326
  - **\`qualification_questions\`** \u2014 the catalog. Each: \`{question, created_at,
6326
6327
  lang}\`. Ordered as the backend returns them.
6327
- - **\`count\`** \u2014 number of configured questions.
6328
+ - **\`count\`** \u2014 number of configured questions. **Zero is a finding, not a
6329
+ blank** \u2014 an org with no questions scores every lead on firmographics alone.
6330
+ Say so and offer a starter set.
6331
+ - **\`ideal_buyer_profile\`** \u2014 \`{summary, key_characteristics, anti_patterns}\`
6332
+ or null. The questions score against THIS. A rule the user states is often
6333
+ already an \`anti_pattern\` here.
6334
+ - **\`targeting_prompt\`** \u2014 the org's free-text instruction to the AI agent, or
6335
+ null. Qualitative rules that no single yes/no can express live here; change
6336
+ it with \`leadbay_refine_prompt\`.
6328
6337
  - **\`is_admin\`** \u2014 whether the current user is an org admin. Modifying the
6329
6338
  questions (\`leadbay_set_qualification_questions\`) is an org-admin action; for
6330
6339
  admins a \`hint\` points there.
@@ -6332,25 +6341,132 @@ Returns:
6332
6341
  when no questions are configured.
6333
6342
 
6334
6343
  This tool only READS. To change the questions, use
6335
- **leadbay_set_qualification_questions** (add / remove / replace). The result is
6336
- cached on the client (it reuses the same taste-profile fetch as
6337
- \`leadbay_get_taste_profile\`), so repeated calls in a session are cheap.
6344
+ **leadbay_set_qualification_questions**; to change the targeting prompt, use
6345
+ **leadbay_refine_prompt**; for sector / size / territory, use
6346
+ **leadbay_adjust_audience**. **leadbay_research_lead_by_id** shows how a
6347
+ SPECIFIC lead answered these questions.
6348
+
6349
+ ### A stated fit rule is a SETTING \u2014 read, decide, propose, then write
6350
+
6351
+ **Never answer a stated rule from memory.** "C'est not\xE9", "already applied",
6352
+ "I'll keep that in mind", "the rule is now active" \u2014 every one of those is a
6353
+ claim about the user's ACCOUNT, and it is false unless a tool call made it
6354
+ true. The rule lives in the org's settings or it does not exist: your context
6355
+ window ends with this conversation, and the next session, the scheduled run and
6356
+ the user's colleague all read the account, not your memory. If you have not
6357
+ called a tool, do not say the rule is in place.
6358
+
6359
+ When the user says what makes a lead good or bad \u2014 *"\xE9carte les soci\xE9t\xE9s
6360
+ liquid\xE9es"*, *"je ne veux pas d'associations"*, *"our best customers run their
6361
+ own maintenance crews"*, *"les leads ne sont pas pertinents"* \u2014 they are
6362
+ describing their account, not just this batch. Filter the batch and they say
6363
+ it again next week; their questions, buyer profile and targeting prompt never
6364
+ move.
6365
+
6366
+ **1 \u2014 Read before you decide.** Call \`leadbay_get_qualification_questions\`
6367
+ first. It returns the question set PLUS the ideal buyer profile and the
6368
+ targeting prompt those questions sit beside. You cannot judge whether a rule is
6369
+ already covered without seeing them.
6370
+
6371
+ **2 \u2014 Decide WHERE the rule belongs.** One rule, one destination:
6372
+
6373
+ | What the user stated | Where it belongs |
6374
+ |---|---|
6375
+ | A sector, a headcount band, a territory | \`leadbay_adjust_audience\` / \`leadbay_new_lens\` filters \u2014 never a question |
6376
+ | A company trait a stranger could estimate from that company's own website or registry record \u2014 "runs its own maintenance crew", "operates a large vehicle fleet", "is legally active and not in liquidation" | a qualification question |
6377
+ | A qualitative orientation too broad for one yes/no \u2014 "we sell to the private sector, not the public one", "harden the exclusion on the business model" | the targeting prompt, \`leadbay_refine_prompt\` |
6378
+ | Named companies \u2014 "exclude Groupe Solidum, Dentego" | \`leadbay_dislike_lead\` / \`leadbay_set_lead_status\` on those leads. A question must NEVER name a company |
6379
+ | CRM state \u2014 "already contacted", "already in a campaign", "already excluded" | read it: \`leadbay_pull_followups\`, \`leadbay_list_campaigns\`. A question cannot observe your own history |
6380
+ | A delivery requirement \u2014 "email AND phone mandatory", "only score 54\u201395" | enrichment plus your own post-filter of the result. A question scores the COMPANY; it cannot see whether Leadbay holds a phone number for a contact |
6381
+ | An event or purchase trigger \u2014 "currently hiring an SDR", "just opened a site" | a qualification question or the targeting prompt. NEVER an \`example_lead\` description or a \`query\`: those match stable registry text, which never mentions events |
6382
+
6383
+ **3 \u2014 Decide whether to change anything at all.** Touching a question
6384
+ re-scores every lead in the pipeline and draws on the org's quota, so a change
6385
+ that surfaces the same companies is a pure loss. Four reasons to write NOTHING
6386
+ and say why:
6387
+
6388
+ 1. **An existing question already covers the rule.** Quote that question back
6389
+ and stop. **Never reword a question that already means the same thing** \u2014
6390
+ even when the user asks you to "clarify" or "improve" the wording. A reword
6391
+ is a removal plus an addition: it re-scores every lead, spends quota, and
6392
+ surfaces exactly the same companies. Offer instead to find out whether any
6393
+ question is testing the WRONG thing.
6394
+ 2. **The audience filter already enforces it** \u2014 sector, headcount, territory.
6395
+ 3. **An existing question already tests that dimension.** Two questions on one
6396
+ dimension waste a slot and add no signal.
6397
+ 4. **The question the user asked for is not decisive.** See step 4: say so,
6398
+ offer the sharper version, and write only what they then choose. Adding a
6399
+ question you know separates nothing is worse than adding none.
6400
+
6401
+ **The ceiling is 5 questions.** Read the count before you answer an "add a
6402
+ question" request: at 5 the honest answer is not "sure, I'll add it". Say in
6403
+ that same turn that the set is full, list the five, and let the USER name which
6404
+ one goes. Never pre-pick the casualty.
6405
+
6406
+ **4 \u2014 Write a DECISIVE question.** Check all six before you propose the text:
6407
+
6408
+ 1. **The estimative marker is literal and mandatory.** English questions start
6409
+ \`Is the company likely to \u2026\`. French questions start
6410
+ \`L'entreprise est-elle susceptible de/d' \u2026\`. There is no third form. The
6411
+ scorer works from public text it cannot verify, so a verifiable question
6412
+ scores almost everything as no.
6413
+ - \u2705 \`Is the company likely to run its own in-house maintenance crew?\`
6414
+ - \u274C \`Does the company run its own maintenance crew?\`
6415
+ - \u274C \`Is the company a cold-storage plant?\` \u2014 no \`likely to\`
6416
+ - \u2705 \`L'entreprise est-elle susceptible d'\xEAtre en liquidation judiciaire ?\`
6417
+ - \u274C \`L'entreprise est-elle en liquidation ?\` \u2014 no \`susceptible\`
6418
+ 2. **It tests the lead as a BUYER.** Before you write a question, ask: would
6419
+ a company answering yes write a cheque to THIS user? A question that only
6420
+ describes what the lead's own business does \u2014 "Is the company likely to
6421
+ manufacture branded pharmaceuticals?" for a seller of advertising \u2014 is a
6422
+ category test, not a buying test, and it scores the user's competitors and
6423
+ suppliers as well as their prospects. Test the need the user's offering
6424
+ meets: "Is the company likely to run consumer campaigns that need paid media
6425
+ placement?"
6426
+ 3. **Estimable from public material** \u2014 the company's website, its about page,
6427
+ its job postings, its registry entry. Never its budget, its internal plans
6428
+ or its future intentions. When the user's rule is un-observable ("has budget
6429
+ for copywriting"), propose the observable proxy, and tell them you swapped
6430
+ it and why.
6431
+ 4. **One dimension each.** \`Is the company likely to operate a cold-storage
6432
+ plant AND run its own maintenance crew?\` is two questions. Split it into
6433
+ two, or pick the one that discriminates better.
6434
+ 5. **Decisive.** It should split companies in general roughly 30/70 while the
6435
+ user's own customers answer yes. A question nearly everyone answers yes to
6436
+ \u2014 "has a website", "uses email", "is a company" \u2014 separates nobody. Do NOT
6437
+ write it as asked: say it would add no signal, and offer the sharper version
6438
+ you would write instead.
6439
+ 6. **\u2264120 characters, in the user's language.**
6440
+
6441
+ An org with **zero** questions scores every lead on firmographics alone. That
6442
+ is a finding worth stating, and the fix is a starter set of **3** questions on
6443
+ three different dimensions \u2014 not one. Propose all three at once.
6444
+
6445
+ **5 \u2014 The change is the user's call, not yours.** This holds for the questions,
6446
+ the targeting prompt and the buyer profile alike. Show the exact text you
6447
+ propose and what it will change, then get an explicit yes before calling
6448
+ \`leadbay_set_qualification_questions\` or \`leadbay_refine_prompt\`. Ask through
6449
+ \`ask_user_input_v0\` when the host offers it. A removal or a swap additionally
6450
+ needs \`confirm:true\`. Do not write an org setting in the same turn the user
6451
+ first stated the rule \u2014 and once they have said yes, actually write it:
6452
+ describing the change is not making it.
6453
+
6454
+ **Answer the ask as well.** A rule stated in passing \u2014 *"sors-moi les leads du
6455
+ jour, et arr\xEAte de me remonter des h\xF4pitaux publics"* \u2014 does not replace the
6456
+ ask. Deliver the leads first, then raise the setting.
6338
6457
 
6339
- Companion tools: **leadbay_set_qualification_questions** to modify the questions;
6340
- **leadbay_get_taste_profile** when the user also wants the Ideal Buyer Profile +
6341
- purchase-intent tags; **leadbay_research_lead_by_id** for how a SPECIFIC lead
6342
- answered these questions; **leadbay_refine_prompt** to shape the AI agent's
6343
- behaviour.
6344
6458
 
6345
6459
  ### RENDERING
6346
6460
 
6347
6461
  Render \`qualification_questions\` as a numbered list \u2014 one question per line, in
6348
6462
  the order returned. Lead with a short heading like **"Qualification questions
6349
- (N)"**. When \`qualification_questions\` is empty, render the \`hint\` sentence
6350
- instead of an empty list. When \`is_admin\` is true and there are questions,
6351
- append the \`hint\` as a one-line footnote (points at
6352
- leadbay_set_qualification_questions). Do not invent questions or reword them \u2014
6353
- render verbatim.
6463
+ (N)"**. Then, when present, the \`ideal_buyer_profile\` summary with its
6464
+ \`anti_patterns\` as a short bulleted list, and the \`targeting_prompt\` as a
6465
+ blockquote. When \`qualification_questions\` is empty, say **"You have no
6466
+ qualification questions \u2014 leads are scored on firmographics alone"** and render
6467
+ the \`hint\`. When \`is_admin\` is true and there are questions, append the \`hint\`
6468
+ as a one-line footnote. Do not invent questions or reword them \u2014 render
6469
+ verbatim.
6354
6470
  `;
6355
6471
  var leadbay_get_quota = `Read quota / spend across daily, weekly, and monthly windows. The response has two scope groups: **\`user\`** (present for every caller) and **\`org\`** (admin-only \u2014 \`null\` for non-admins). **Read from \`user\` first**, falling back to \`org\` only when \`user\` is absent. Each group carries \`spend[]\` (the dollar-spend gauge: \`current_units\` / \`max_units\` in dollar_cents \u2192 % used = the ratio, $ = \`/100\`) and \`resources[]\` (per-resource usage: \`{resource_type, count (used), max_units (cap or null), window_type, resets_at}\`). \`spend[]\` is empty for orgs with no OVERALL_SPEND quota \u2014 fall back to the \`resources[]\` counts then. There is also a top-level \`topup\` ({remaining_cents, total_credit_cents}) when present. Resource types may arrive lowercase (\`lens_extra_refill\`) or uppercase \u2014 match case-insensitively. Present quota as a percentage / dollar figure, never raw "credits".
6356
6472
 
@@ -8438,20 +8554,22 @@ WHEN NOT TO USE: when you already know the exact titles you want to enrich.
8438
8554
  `;
8439
8555
  var leadbay_refine_prompt = `## WHEN TO USE
8440
8556
 
8441
- Trigger phrases: "focus on companies that <qualitative trait>", "I prefer leads that <behavior/characteristic>", "prioritize companies running their own IT", "deprioritize companies that just raised".
8557
+ Trigger phrases: "focus on companies that <qualitative trait>", "I prefer leads that <behavior/characteristic>", "prioritize companies running their own IT", "deprioritize companies that just raised", "stop showing me <kind of company>", "we only sell to the private sector, not the public one", "harden the exclusion on <business model>, not the keyword", "competitors / resellers are never prospects for us".
8442
8558
 
8443
- Do NOT use for: "create a new lens / a lens specialized into <X>" \u2192 \`leadbay_new_lens\`; "add/remove <sector> to/from my <name> lens" \u2192 \`leadbay_adjust_audience\`; "narrow the audience to <sector> / <size>" \u2192 \`leadbay_adjust_audience\`; "show me / list / switch my lenses" \u2192 \`leadbay_my_lenses\`.
8559
+ Do NOT use for: "create a new lens / a lens specialized into <X>" \u2192 \`leadbay_new_lens\`; "add/remove <sector> to/from my <name> lens" \u2192 \`leadbay_adjust_audience\`; "narrow the audience to <sector> / <size>" \u2192 \`leadbay_adjust_audience\`; "show me / list / switch my lenses" \u2192 \`leadbay_my_lenses\`; "a company trait one yes/no question could estimate from their website" \u2192 \`leadbay_set_qualification_questions\`; "exclude <named company>" \u2192 \`leadbay_dislike_lead\`.
8444
8560
 
8445
- Prefer when: ADMIN-ONLY. Qualitative refinement of the active lens that sector/size can't express. Creating/naming/listing/switching/sector-editing a lens routes elsewhere. Non-admin user \u2192 do NOT pick this.
8561
+ Prefer when: ADMIN-ONLY. A qualitative ORIENTATION too broad for one yes/no \u2014 a segment to avoid, a business model to rule out. One estimable company trait goes to set_qualification_questions; a named company to dislike_lead.
8446
8562
 
8447
8563
  Examples that SHOULD invoke this tool:
8448
8564
  - "Focus on hospitals that run their own IT in-house."
8449
8565
  - "Prioritize companies that have recently expanded headcount."
8566
+ - "On ne vend qu'au priv\xE9 \u2014 arr\xEAte de me remonter des h\xF4pitaux publics."
8450
8567
 
8451
8568
  Examples that should NOT invoke this tool (sound similar, route elsewhere):
8452
8569
  - "Create a lens specialized in automobile."
8453
8570
  - "Add fintech to my Joinery lens."
8454
8571
  - "Show me my lenses."
8572
+ - "Exclude Groupe Solidum, we had an unpaid invoice with them."
8455
8573
 
8456
8574
  ## RENDER (quick)
8457
8575
 
@@ -8462,6 +8580,121 @@ clarification was raised, surface its question (route via ask_user_input_v0).
8462
8580
 
8463
8581
  Refine the kind of leads Leadbay surfaces, beyond firmographics. Free-text instruction (e.g. "focus on hospitals running their own IT"). Sets the org's \`user_prompt\`; if the new prompt produces ambiguous criteria, Leadbay raises a clarification question, which this composite polls for and surfaces. Admin-only on the backend (will return 403 for non-admins).
8464
8582
 
8583
+ **Read \`leadbay_get_qualification_questions\` first.** It returns the current
8584
+ targeting prompt alongside the questions and the buyer profile. Setting a
8585
+ prompt REPLACES the previous one \u2014 write the combined instruction, not just the
8586
+ new clause, or you silently drop rules the user set earlier.
8587
+
8588
+ ### A stated fit rule is a SETTING \u2014 read, decide, propose, then write
8589
+
8590
+ **Never answer a stated rule from memory.** "C'est not\xE9", "already applied",
8591
+ "I'll keep that in mind", "the rule is now active" \u2014 every one of those is a
8592
+ claim about the user's ACCOUNT, and it is false unless a tool call made it
8593
+ true. The rule lives in the org's settings or it does not exist: your context
8594
+ window ends with this conversation, and the next session, the scheduled run and
8595
+ the user's colleague all read the account, not your memory. If you have not
8596
+ called a tool, do not say the rule is in place.
8597
+
8598
+ When the user says what makes a lead good or bad \u2014 *"\xE9carte les soci\xE9t\xE9s
8599
+ liquid\xE9es"*, *"je ne veux pas d'associations"*, *"our best customers run their
8600
+ own maintenance crews"*, *"les leads ne sont pas pertinents"* \u2014 they are
8601
+ describing their account, not just this batch. Filter the batch and they say
8602
+ it again next week; their questions, buyer profile and targeting prompt never
8603
+ move.
8604
+
8605
+ **1 \u2014 Read before you decide.** Call \`leadbay_get_qualification_questions\`
8606
+ first. It returns the question set PLUS the ideal buyer profile and the
8607
+ targeting prompt those questions sit beside. You cannot judge whether a rule is
8608
+ already covered without seeing them.
8609
+
8610
+ **2 \u2014 Decide WHERE the rule belongs.** One rule, one destination:
8611
+
8612
+ | What the user stated | Where it belongs |
8613
+ |---|---|
8614
+ | A sector, a headcount band, a territory | \`leadbay_adjust_audience\` / \`leadbay_new_lens\` filters \u2014 never a question |
8615
+ | A company trait a stranger could estimate from that company's own website or registry record \u2014 "runs its own maintenance crew", "operates a large vehicle fleet", "is legally active and not in liquidation" | a qualification question |
8616
+ | A qualitative orientation too broad for one yes/no \u2014 "we sell to the private sector, not the public one", "harden the exclusion on the business model" | the targeting prompt, \`leadbay_refine_prompt\` |
8617
+ | Named companies \u2014 "exclude Groupe Solidum, Dentego" | \`leadbay_dislike_lead\` / \`leadbay_set_lead_status\` on those leads. A question must NEVER name a company |
8618
+ | CRM state \u2014 "already contacted", "already in a campaign", "already excluded" | read it: \`leadbay_pull_followups\`, \`leadbay_list_campaigns\`. A question cannot observe your own history |
8619
+ | A delivery requirement \u2014 "email AND phone mandatory", "only score 54\u201395" | enrichment plus your own post-filter of the result. A question scores the COMPANY; it cannot see whether Leadbay holds a phone number for a contact |
8620
+ | An event or purchase trigger \u2014 "currently hiring an SDR", "just opened a site" | a qualification question or the targeting prompt. NEVER an \`example_lead\` description or a \`query\`: those match stable registry text, which never mentions events |
8621
+
8622
+ **3 \u2014 Decide whether to change anything at all.** Touching a question
8623
+ re-scores every lead in the pipeline and draws on the org's quota, so a change
8624
+ that surfaces the same companies is a pure loss. Four reasons to write NOTHING
8625
+ and say why:
8626
+
8627
+ 1. **An existing question already covers the rule.** Quote that question back
8628
+ and stop. **Never reword a question that already means the same thing** \u2014
8629
+ even when the user asks you to "clarify" or "improve" the wording. A reword
8630
+ is a removal plus an addition: it re-scores every lead, spends quota, and
8631
+ surfaces exactly the same companies. Offer instead to find out whether any
8632
+ question is testing the WRONG thing.
8633
+ 2. **The audience filter already enforces it** \u2014 sector, headcount, territory.
8634
+ 3. **An existing question already tests that dimension.** Two questions on one
8635
+ dimension waste a slot and add no signal.
8636
+ 4. **The question the user asked for is not decisive.** See step 4: say so,
8637
+ offer the sharper version, and write only what they then choose. Adding a
8638
+ question you know separates nothing is worse than adding none.
8639
+
8640
+ **The ceiling is 5 questions.** Read the count before you answer an "add a
8641
+ question" request: at 5 the honest answer is not "sure, I'll add it". Say in
8642
+ that same turn that the set is full, list the five, and let the USER name which
8643
+ one goes. Never pre-pick the casualty.
8644
+
8645
+ **4 \u2014 Write a DECISIVE question.** Check all six before you propose the text:
8646
+
8647
+ 1. **The estimative marker is literal and mandatory.** English questions start
8648
+ \`Is the company likely to \u2026\`. French questions start
8649
+ \`L'entreprise est-elle susceptible de/d' \u2026\`. There is no third form. The
8650
+ scorer works from public text it cannot verify, so a verifiable question
8651
+ scores almost everything as no.
8652
+ - \u2705 \`Is the company likely to run its own in-house maintenance crew?\`
8653
+ - \u274C \`Does the company run its own maintenance crew?\`
8654
+ - \u274C \`Is the company a cold-storage plant?\` \u2014 no \`likely to\`
8655
+ - \u2705 \`L'entreprise est-elle susceptible d'\xEAtre en liquidation judiciaire ?\`
8656
+ - \u274C \`L'entreprise est-elle en liquidation ?\` \u2014 no \`susceptible\`
8657
+ 2. **It tests the lead as a BUYER.** Before you write a question, ask: would
8658
+ a company answering yes write a cheque to THIS user? A question that only
8659
+ describes what the lead's own business does \u2014 "Is the company likely to
8660
+ manufacture branded pharmaceuticals?" for a seller of advertising \u2014 is a
8661
+ category test, not a buying test, and it scores the user's competitors and
8662
+ suppliers as well as their prospects. Test the need the user's offering
8663
+ meets: "Is the company likely to run consumer campaigns that need paid media
8664
+ placement?"
8665
+ 3. **Estimable from public material** \u2014 the company's website, its about page,
8666
+ its job postings, its registry entry. Never its budget, its internal plans
8667
+ or its future intentions. When the user's rule is un-observable ("has budget
8668
+ for copywriting"), propose the observable proxy, and tell them you swapped
8669
+ it and why.
8670
+ 4. **One dimension each.** \`Is the company likely to operate a cold-storage
8671
+ plant AND run its own maintenance crew?\` is two questions. Split it into
8672
+ two, or pick the one that discriminates better.
8673
+ 5. **Decisive.** It should split companies in general roughly 30/70 while the
8674
+ user's own customers answer yes. A question nearly everyone answers yes to
8675
+ \u2014 "has a website", "uses email", "is a company" \u2014 separates nobody. Do NOT
8676
+ write it as asked: say it would add no signal, and offer the sharper version
8677
+ you would write instead.
8678
+ 6. **\u2264120 characters, in the user's language.**
8679
+
8680
+ An org with **zero** questions scores every lead on firmographics alone. That
8681
+ is a finding worth stating, and the fix is a starter set of **3** questions on
8682
+ three different dimensions \u2014 not one. Propose all three at once.
8683
+
8684
+ **5 \u2014 The change is the user's call, not yours.** This holds for the questions,
8685
+ the targeting prompt and the buyer profile alike. Show the exact text you
8686
+ propose and what it will change, then get an explicit yes before calling
8687
+ \`leadbay_set_qualification_questions\` or \`leadbay_refine_prompt\`. Ask through
8688
+ \`ask_user_input_v0\` when the host offers it. A removal or a swap additionally
8689
+ needs \`confirm:true\`. Do not write an org setting in the same turn the user
8690
+ first stated the rule \u2014 and once they have said yes, actually write it:
8691
+ describing the change is not making it.
8692
+
8693
+ **Answer the ask as well.** A rule stated in passing \u2014 *"sors-moi les leads du
8694
+ jour, et arr\xEAte de me remonter des h\xF4pitaux publics"* \u2014 does not replace the
8695
+ ask. Deliver the leads first, then raise the setting.
8696
+
8697
+
8465
8698
  WHEN TO USE: when audience filters (leadbay_adjust_audience) aren't enough.
8466
8699
 
8467
8700
  WHEN NOT TO USE: to answer a pending clarification \u2014 that's leadbay_answer_clarification.
@@ -8886,13 +9119,11 @@ key. It survives a misspelled company name and is what turns "not in your
8886
9119
  list" into an answer. With only a contact email, pass \`email\`: the company
8887
9120
  domain is derived from it, consumer mailboxes ignored.
8888
9121
 
8889
- When the registry cannot pick one company it returns \`{resolution:
8890
- "ambiguous", query, candidates:[\u2026]}\` instead of a card. Ask which one; never
8891
- guess from \`score\`.
8892
-
8893
- \`LEAD_NOT_FOUND\` is not a dead end: its hint names the field that would have
8894
- found it \u2014 \`website\` or \`registry_number\`, both params. Ask for it and call
8895
- again. Do not offer an import before asking.
9122
+ Both \`resolution\` answers below are successes, not cards. \`"ambiguous"\`
9123
+ carries \`candidates[]\`: ask which one, never guess from \`score\`.
9124
+ \`"not_found"\` carries \`summary\` + \`next_step\`: nobody has it, so say that and
9125
+ do what \`next_step\` says \u2014 usually ask for the param \`would_help\` names, then
9126
+ call again. Do not offer an import before asking.
8896
9127
 
8897
9128
  ---
8898
9129
 
@@ -9033,7 +9264,7 @@ out?"\`
9033
9264
 
9034
9265
  When \`resolution\` is \`"ambiguous"\`, render no card: use \`ask_user_input_v0\`,
9035
9266
  ONE \`single_select\` question ("Which one?"), one short label per candidate
9036
- combining \`name\` and \`location\`.
9267
+ combining \`name\` and \`location\`. \`"not_found"\` renders no card either.
9037
9268
 
9038
9269
  When \`_meta.match_candidates\` is non-empty, prepend one extra NEXT STEPS row:
9039
9270
 
@@ -9513,7 +9744,35 @@ WHEN NOT TO USE: the user is just skipping ONE outreach attempt \u2014 that's a
9513
9744
 
9514
9745
  This tool MUTATES state. The caller (agent or human-in-the-loop) is responsible for confirming intent before invocation; the MCP server does not soft-prompt for confirmation. See \`annotations.destructiveHint\`.
9515
9746
  `;
9516
- var leadbay_set_qualification_questions = `Modify the organization's **qualification questions** \u2014 the AI-agent questions Leadbay scores every lead against. Use when the user wants to add, remove, or rewrite their qualification questions \u2014 e.g. "add a question about whether they run install crews", "remove the flooring question", "replace my questions with these three".
9747
+ var leadbay_set_qualification_questions = `## WHEN TO USE
9748
+
9749
+ Trigger phrases: "add / remove / replace a qualification question", "update my qualification so Leadbay looks for <trait>", "my best customers are <trait> \u2014 target those", "I don't want <kind of company> at all", "the leads aren't relevant, fix my criteria".
9750
+
9751
+ Do NOT use for: "what are my qualification questions" \u2192 \`leadbay_get_qualification_questions\`; "narrow to <sector> / <headcount> / <territory>" \u2192 \`leadbay_adjust_audience\`; "a qualitative orientation one yes/no can't express" \u2192 \`leadbay_refine_prompt\`; "exclude <named company>" \u2192 \`leadbay_dislike_lead\`; "only leads that already have an email and a phone" \u2192 \`leadbay_enrich_titles\`.
9752
+
9753
+ Prefer when: the user stated a durable company TRAIT a stranger could estimate from that company's public material, and get_qualification_questions shows nothing covering it. Read first, propose the text, write only on a yes.
9754
+
9755
+ Examples that SHOULD invoke this tool:
9756
+ - "Our best customers are cold-storage plants that run their own maintenance crews \u2014 update my qualification for that."
9757
+ - "\xC9carte les soci\xE9t\xE9s en liquidation, je ne veux plus les voir."
9758
+ - "Remove the flooring question and add one about install crews."
9759
+
9760
+ Examples that should NOT invoke this tool (sound similar, route elsewhere):
9761
+ - "What qualification questions does Leadbay use?"
9762
+ - "Add fintech to my Joinery lens."
9763
+ - "Exclude Groupe Solidum, we had an unpaid invoice with them."
9764
+
9765
+ ## RENDER (quick)
9766
+
9767
+ Before writing: show the exact question text you propose, say what it
9768
+ changes, and ask for a yes (route via ask_user_input_v0). After writing:
9769
+ one confirmation line ("Added 1 question \u2014 you now score leads against 4
9770
+ questions.") then the resulting questions as a numbered list. On a
9771
+ non-changing preview, surface the \`hint\` and ask \u2014 never auto-confirm.
9772
+
9773
+ ---
9774
+
9775
+ Modify the organization's **qualification questions** \u2014 the AI-agent questions Leadbay scores every lead against. Use when the user wants to add, remove, or rewrite them \u2014 e.g. "add a question about whether they run install crews", "remove the flooring question", "replace my questions with these three".
9517
9776
 
9518
9777
  The backend stores the list as a whole, so this tool reads the current questions and applies your change:
9519
9778
 
@@ -9525,11 +9784,142 @@ Leadbay allows **at most 5** qualification questions. If a change would exceed 5
9525
9784
 
9526
9785
  **Dropping any existing question is destructive** \u2014 it changes how every lead is scored. Any change that removes a current question requires \`confirm:true\` \u2014 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.
9527
9786
 
9528
- 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 \u2026?").
9787
+ Returns the resulting \`{qualification_questions, count, previous_count, changed}\`.
9788
+
9789
+ ### A stated fit rule is a SETTING \u2014 read, decide, propose, then write
9790
+
9791
+ **Never answer a stated rule from memory.** "C'est not\xE9", "already applied",
9792
+ "I'll keep that in mind", "the rule is now active" \u2014 every one of those is a
9793
+ claim about the user's ACCOUNT, and it is false unless a tool call made it
9794
+ true. The rule lives in the org's settings or it does not exist: your context
9795
+ window ends with this conversation, and the next session, the scheduled run and
9796
+ the user's colleague all read the account, not your memory. If you have not
9797
+ called a tool, do not say the rule is in place.
9798
+
9799
+ When the user says what makes a lead good or bad \u2014 *"\xE9carte les soci\xE9t\xE9s
9800
+ liquid\xE9es"*, *"je ne veux pas d'associations"*, *"our best customers run their
9801
+ own maintenance crews"*, *"les leads ne sont pas pertinents"* \u2014 they are
9802
+ describing their account, not just this batch. Filter the batch and they say
9803
+ it again next week; their questions, buyer profile and targeting prompt never
9804
+ move.
9805
+
9806
+ **1 \u2014 Read before you decide.** Call \`leadbay_get_qualification_questions\`
9807
+ first. It returns the question set PLUS the ideal buyer profile and the
9808
+ targeting prompt those questions sit beside. You cannot judge whether a rule is
9809
+ already covered without seeing them.
9810
+
9811
+ **2 \u2014 Decide WHERE the rule belongs.** One rule, one destination:
9812
+
9813
+ | What the user stated | Where it belongs |
9814
+ |---|---|
9815
+ | A sector, a headcount band, a territory | \`leadbay_adjust_audience\` / \`leadbay_new_lens\` filters \u2014 never a question |
9816
+ | A company trait a stranger could estimate from that company's own website or registry record \u2014 "runs its own maintenance crew", "operates a large vehicle fleet", "is legally active and not in liquidation" | a qualification question |
9817
+ | A qualitative orientation too broad for one yes/no \u2014 "we sell to the private sector, not the public one", "harden the exclusion on the business model" | the targeting prompt, \`leadbay_refine_prompt\` |
9818
+ | Named companies \u2014 "exclude Groupe Solidum, Dentego" | \`leadbay_dislike_lead\` / \`leadbay_set_lead_status\` on those leads. A question must NEVER name a company |
9819
+ | CRM state \u2014 "already contacted", "already in a campaign", "already excluded" | read it: \`leadbay_pull_followups\`, \`leadbay_list_campaigns\`. A question cannot observe your own history |
9820
+ | A delivery requirement \u2014 "email AND phone mandatory", "only score 54\u201395" | enrichment plus your own post-filter of the result. A question scores the COMPANY; it cannot see whether Leadbay holds a phone number for a contact |
9821
+ | An event or purchase trigger \u2014 "currently hiring an SDR", "just opened a site" | a qualification question or the targeting prompt. NEVER an \`example_lead\` description or a \`query\`: those match stable registry text, which never mentions events |
9822
+
9823
+ **3 \u2014 Decide whether to change anything at all.** Touching a question
9824
+ re-scores every lead in the pipeline and draws on the org's quota, so a change
9825
+ that surfaces the same companies is a pure loss. Four reasons to write NOTHING
9826
+ and say why:
9827
+
9828
+ 1. **An existing question already covers the rule.** Quote that question back
9829
+ and stop. **Never reword a question that already means the same thing** \u2014
9830
+ even when the user asks you to "clarify" or "improve" the wording. A reword
9831
+ is a removal plus an addition: it re-scores every lead, spends quota, and
9832
+ surfaces exactly the same companies. Offer instead to find out whether any
9833
+ question is testing the WRONG thing.
9834
+ 2. **The audience filter already enforces it** \u2014 sector, headcount, territory.
9835
+ 3. **An existing question already tests that dimension.** Two questions on one
9836
+ dimension waste a slot and add no signal.
9837
+ 4. **The question the user asked for is not decisive.** See step 4: say so,
9838
+ offer the sharper version, and write only what they then choose. Adding a
9839
+ question you know separates nothing is worse than adding none.
9840
+
9841
+ **The ceiling is 5 questions.** Read the count before you answer an "add a
9842
+ question" request: at 5 the honest answer is not "sure, I'll add it". Say in
9843
+ that same turn that the set is full, list the five, and let the USER name which
9844
+ one goes. Never pre-pick the casualty.
9845
+
9846
+ **4 \u2014 Write a DECISIVE question.** Check all six before you propose the text:
9847
+
9848
+ 1. **The estimative marker is literal and mandatory.** English questions start
9849
+ \`Is the company likely to \u2026\`. French questions start
9850
+ \`L'entreprise est-elle susceptible de/d' \u2026\`. There is no third form. The
9851
+ scorer works from public text it cannot verify, so a verifiable question
9852
+ scores almost everything as no.
9853
+ - \u2705 \`Is the company likely to run its own in-house maintenance crew?\`
9854
+ - \u274C \`Does the company run its own maintenance crew?\`
9855
+ - \u274C \`Is the company a cold-storage plant?\` \u2014 no \`likely to\`
9856
+ - \u2705 \`L'entreprise est-elle susceptible d'\xEAtre en liquidation judiciaire ?\`
9857
+ - \u274C \`L'entreprise est-elle en liquidation ?\` \u2014 no \`susceptible\`
9858
+ 2. **It tests the lead as a BUYER.** Before you write a question, ask: would
9859
+ a company answering yes write a cheque to THIS user? A question that only
9860
+ describes what the lead's own business does \u2014 "Is the company likely to
9861
+ manufacture branded pharmaceuticals?" for a seller of advertising \u2014 is a
9862
+ category test, not a buying test, and it scores the user's competitors and
9863
+ suppliers as well as their prospects. Test the need the user's offering
9864
+ meets: "Is the company likely to run consumer campaigns that need paid media
9865
+ placement?"
9866
+ 3. **Estimable from public material** \u2014 the company's website, its about page,
9867
+ its job postings, its registry entry. Never its budget, its internal plans
9868
+ or its future intentions. When the user's rule is un-observable ("has budget
9869
+ for copywriting"), propose the observable proxy, and tell them you swapped
9870
+ it and why.
9871
+ 4. **One dimension each.** \`Is the company likely to operate a cold-storage
9872
+ plant AND run its own maintenance crew?\` is two questions. Split it into
9873
+ two, or pick the one that discriminates better.
9874
+ 5. **Decisive.** It should split companies in general roughly 30/70 while the
9875
+ user's own customers answer yes. A question nearly everyone answers yes to
9876
+ \u2014 "has a website", "uses email", "is a company" \u2014 separates nobody. Do NOT
9877
+ write it as asked: say it would add no signal, and offer the sharper version
9878
+ you would write instead.
9879
+ 6. **\u2264120 characters, in the user's language.**
9880
+
9881
+ An org with **zero** questions scores every lead on firmographics alone. That
9882
+ is a finding worth stating, and the fix is a starter set of **3** questions on
9883
+ three different dimensions \u2014 not one. Propose all three at once.
9884
+
9885
+ **5 \u2014 The change is the user's call, not yours.** This holds for the questions,
9886
+ the targeting prompt and the buyer profile alike. Show the exact text you
9887
+ propose and what it will change, then get an explicit yes before calling
9888
+ \`leadbay_set_qualification_questions\` or \`leadbay_refine_prompt\`. Ask through
9889
+ \`ask_user_input_v0\` when the host offers it. A removal or a swap additionally
9890
+ needs \`confirm:true\`. Do not write an org setting in the same turn the user
9891
+ first stated the rule \u2014 and once they have said yes, actually write it:
9892
+ describing the change is not making it.
9893
+
9894
+ **Answer the ask as well.** A rule stated in passing \u2014 *"sors-moi les leads du
9895
+ jour, et arr\xEAte de me remonter des h\xF4pitaux publics"* \u2014 does not replace the
9896
+ ask. Deliver the leads first, then raise the setting.
9897
+
9898
+
9899
+ WHEN TO USE: the user stated a durable company trait no existing question covers, and they have agreed to the exact text you proposed.
9900
+
9901
+ WHEN NOT TO USE: to READ the questions (use leadbay_get_qualification_questions), to change a single lead's data, or for any rule the table above sends elsewhere. This is org-level \u2014 it affects scoring for ALL leads.
9902
+
9903
+ ## GATE \u2014 PREFER BUILT-IN HOST WIDGETS
9904
+
9905
+ Modern chat hosts (Claude, ChatGPT) expose first-party widgets the agent can route into. These ALWAYS produce a better UX than markdown tables / inline prose for the data shapes they support \u2014 they're tappable on mobile, persistent across turns, and integrate with the host's quick-actions.
9906
+
9907
+ **The Big Three** \u2014 when a tool result fits, route there:
9908
+
9909
+ | Host widget | Use when | Field map (from Leadbay payload) |
9910
+ |---|---|---|
9911
+ | \`places_map_display_v0\` + \`places_search\` (Claude) | \u22652 leads with coords / \`location.city\`, geographic / "in person" / travel intent | **Two-step**: \`places_search\` each lead (query = company + full street address) \u2192 real \`place_id\`/coords, THEN render with \`places_map_display_v0\` (Itinerary mode for a tour). Skipping \`places_search\` \u2192 schematic scatter, not a street map. |
9912
+ | \`message_compose_v1\` (Claude) | You're about to draft outreach (email / message / call opener) | \`{kind: "email", summary_title, variants: [{label, body, subject}]}\` \u2014 2\u20133 variants, labels describe STRATEGY ("Push for alignment", "Reference the M&A signal"), not tone ("Friendly", "Formal") |
9913
+ | \`ask_user_input_v0\` (Claude chat / ChatGPT) **or** \`AskUserQuestion\` (Claude cowork / Claude Code) \u2014 whichever is in your tool set; their schemas differ, match the one you have | The tool's NEXT STEPS block has 2\u20134 mutually-exclusive next moves and the user hasn't already chosen | Per-tool schema in the server instructions + NEXT STEPS routing block. Max 3 questions. |
9914
+
9915
+ ChatGPT exposes the same routing pattern via \`_meta.openai/outputTemplate\`. We don't ship any custom widgets ourselves \u2014 this gate is exclusively about routing into the host's first-party widgets when the data shape fits.
9529
9916
 
9530
- WHEN TO USE: the user wants to change the org's qualification questions.
9917
+ **Rules:**
9918
+ - The widget IS the visual. Do NOT emit a markdown table or prose list of the same data alongside \u2014 that produces two competing UIs.
9919
+ - Pass identifiers (place_id, lead.id, contact_id) verbatim. Don't rewrite.
9920
+ - When the host doesn't expose the named widget, the agent falls back to the prose/table rendering the per-tool description already specifies. The directive is host-conditional; the fallback is automatic.
9921
+ - One short intro sentence in chat is enough \u2014 "Here are your 5 NYC follow-ups." Then route into the widget.
9531
9922
 
9532
- 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 \u2014 it affects scoring for ALL leads.
9533
9923
 
9534
9924
  ### RENDERING
9535
9925
 
@@ -9646,17 +10036,18 @@ WHEN NOT TO USE: the user wants a lead list (leadbay_pull_leads / leadbay_pull_f
9646
10036
  `;
9647
10037
  var leadbay_tour_plan = `## WHEN TO USE
9648
10038
 
9649
- Trigger phrases: "visiting <city> in <N> days", "I'm in <city> next week / Tuesday \u2014 who's worth meeting", "I'm going to <city> \u2014 who should I see", "who's worth meeting in <city>", "field tour in <city>", "plan a tour in <city>", "who should I meet in <city>", "customers plus prospects in <city>", "tour itinerary".
10039
+ Trigger phrases: "visiting <city> in <N> days", "I'm in <city> next week / Tuesday \u2014 who's worth meeting", "I'm going to <city> \u2014 who should I see", "who's worth meeting in <city>", "field tour in <city>", "plan a tour in <city>", "who should I meet in <city>", "prospects within <N> km of <city>", "<city> and the surrounding area", "customers plus prospects in <city>", "tour itinerary".
9650
10040
 
9651
10041
  Do NOT use for: "follow-ups only, no new prospects" \u2192 \`leadbay_followups_map\`; "new leads only" \u2192 \`leadbay_pull_leads\`; "research one account" \u2192 \`leadbay_research_lead_by_id\`.
9652
10042
 
9653
- Prefer when: user wants known accounts plus new discoveries in one geographic itinerary; NEVER a country name \u2014 unlike the Monitor tools, do NOT omit \`city\`; a city-less tour is arbitrary nationwide leads, so ask which city or region
10043
+ Prefer when: known accounts plus new discoveries in one itinerary; pass \`radius_km\` when they name a radius; NEVER a country name, and do NOT omit \`city\`: a city-less tour is arbitrary nationwide leads, so ask which city or region
9654
10044
 
9655
10045
  Examples that SHOULD invoke this tool:
9656
10046
  - "I'm flying to Limoges in 4 days \u2014 give me 3 customers, 3 qualified prospects, and 3 new high-potential."
9657
10047
  - "I'm in San Francisco next Tuesday. Who's worth meeting?"
9658
10048
  - "Plan my tour next Tuesday in Lyon: known accounts plus discoveries."
9659
10049
  - "Build a mixed itinerary for Berlin \u2014 I want both follow-ups and fresh leads."
10050
+ - "J'ai un rdv le 19 ao\xFBt \xE0 Colmar \u2014 trouve-moi les prospects dans un rayon de 10km."
9660
10051
 
9661
10052
  Examples that should NOT invoke this tool (sound similar, route elsewhere):
9662
10053
  - "Show me my follow-ups for the SF trip."
@@ -9711,6 +10102,8 @@ same instruction in its \`hint\`.
9711
10102
 
9712
10103
  **Counts**: \`followups_count\` (default 6 \u2014 generous so the agent can split into "customers + qualified" client-side) and \`discover_count\` (default 6 after client-side geo filter). The composite over-pulls Discover (30 raw) because the wishlist endpoint has no server-side geo filter \u2014 it then keeps the leads whose own \`location.city\` names the requested city, and falls back to \`location.state\` only when no city matched (which is what a regional ask like "Texas" or "\xCEle-de-France" looks like). \`location.country\` is never consulted. \`discover_filter_note\` reports the ratio and which field carried it, so the agent can be honest about coverage. **When it says no Discover lead is in the city, say that** \u2014 return the Monitor half and offer \`leadbay_find_new_leads\` for that city. Never fill the gap with leads from elsewhere.
9713
10104
 
10105
+ **The next town over**: a tour is a day of driving, so after the town's own leads are found the composite adds the Discover leads whose own coordinates put them within **\`radius_km\` (default 20)** of it \u2014 West Sacramento on a tour of Sacramento, Courbevoie and Ivry-sur-Seine on a tour of Paris. The town's own leads always come first. **Pass the user's own number when they give one** ("dans un rayon de 10km autour de Colmar" \u2192 \`radius_km: 10\`; "within 15 miles" \u2192 \`radius_km: 24\`), and \`radius_km: 0\` to keep the tour strictly inside the named town. The radius applies to Discover leads only; the Monitor half is scoped server-side and is untouched. When \`discover_filter_note\` splits the stops into "N in '<city>' and M in <other towns>", **repeat that split** \u2014 say which town each nearby stop is actually in rather than presenting every stop as being in the city the user named.
10106
+
9714
10107
  **What \`tour_plan\` does NOT do**: it doesn't persist the tour as a campaign artifact. To do that \u2014 create a "Limoges Tour \u2013 May 24" campaign and attach the selected accounts \u2014 chain into \`leadbay_create_campaign({lead_ids: [...selected_ids], name: 'Limoges Tour \u2013 <date>'})\` after the user picks. See the \`leadbay_plan_tour_in_city\` prompt for the full end-to-end orchestrator.
9715
10108
 
9716
10109
  ---
@@ -10123,7 +10516,7 @@ Do NOT use for: "show me today's leads / what's new today" \u2192 \`leadbay_pull
10123
10516
  Prefer when: the user describes a target profile or names a count of NEW companies \u2014 craft the example_lead per the seed rules below BEFORE calling; never pass the user's raw sentence as query.
10124
10517
 
10125
10518
  Examples that SHOULD invoke this tool:
10126
- - "Find me 10 gyms around Dallas that would buy our flooring, with someone I can call."
10519
+ - "Find me 10 gyms around Dallas that would buy our flooring, with a contact."
10127
10520
  - "Get me 20 new US SaaS companies, 50-2000 employees, with the VP People's email."
10128
10521
  - "We're launching in Lyon \u2014 find 15 hotels that fit our ICP."
10129
10522
 
@@ -10134,11 +10527,10 @@ Examples that should NOT invoke this tool (sound similar, route elsewhere):
10134
10527
 
10135
10528
  ## RENDER (quick)
10136
10529
 
10137
- 3-col table of delivered leads in returned order: col 1 = 10-segment fit
10138
- bar + linked company \xB7 location \xB7 size; col 2 = why-fits \u226420 words; col 3
10139
- = contact + found channels. ALWAYS close with the honest funnel line
10140
- (matched/examined/delivered/stop reason) \u2014 especially on 0
10141
- delivered. Full algorithm below.
10530
+ 3-col table of delivered leads in returned order: col 1 = 10-segment fit bar
10531
+ + linked company \xB7 location \xB7 size; col 2 = why-fits \u226420 words; col 3 =
10532
+ contact + found channels. ALWAYS close with the honest funnel line
10533
+ (matched/examined/delivered/stop reason), especially on 0 delivered.
10142
10534
 
10143
10535
  ---
10144
10536
 
@@ -10147,27 +10539,25 @@ company universe, applies hard filters, skips what the org already knows
10147
10539
  (\`novelty: org\`), optionally qualifies against the org's own intelligence
10148
10540
  (questions, tags, ideal buyer profile \u2014 frozen at submit), and optionally reveals
10149
10541
  contact channels. Polls up to \`wait_seconds\` (default 45); a longer job returns
10150
- \`still_running\` + \`next_poll\` \u2014 hand off to \`leadbay_lead_job_status\`. Jobs run
10542
+ \`still_running\` + \`next_poll\` \u2014 hand to \`leadbay_lead_job_status\`. Jobs run
10151
10543
  \u226430 min, results kept 30 days.
10152
10544
 
10153
10545
  **Free vs usage quota \u2014 never use quota silently.** Default (\`qualify: false\`,
10154
10546
  \`channels: []\`) is FREE: company profile + fit score + cached research +
10155
10547
  contact identity. \`qualify: true\` (per candidate EXAMINED, capped by
10156
10548
  \`exploration_cap\`/\`max_cost\`) and \`channels\` (only when a value is found) draw
10157
- on the org's usage quota; nothing is invoiced. Enforced in code: such a call is WITHHELD unless it
10158
- carries \`confirm: true\` \u2014 nothing is submitted and you get
10549
+ on the org's usage quota; nothing is invoiced. Enforced in code: such a call is
10550
+ WITHHELD unless it carries \`confirm: true\` \u2014 nothing is submitted and you get
10159
10551
  \`mode: "needs_confirmation"\` with a real quote to show the user. Re-call with
10160
10552
  \`confirm: true\` on their go-ahead ("go ahead / get their emails" counts).
10161
- \`confirm: false\` vetoes. Free needs no consent. **Preview free first** \u2014
10162
- reshaping an off-profile seed is free, exploring it with \`qualify: true\` is
10163
- not.
10553
+ \`confirm: false\` vetoes. **Preview free first** \u2014 reshaping an off-profile seed
10554
+ is free, exploring it with \`qualify: true\` is not.
10164
10555
 
10165
10556
  **Ad-hoc exclusions ("no chains") are enforced by NO tier** \u2014 \`filters\` has no
10166
- exclusion key, and \`qualify\` scores against the org's FROZEN questions and IBP,
10167
- which need not mention chains; the seed's inverse only shifts ranking.
10168
- Violators can survive, use quota and be delivered \u2014 post-filter them yourself
10169
- and say the tier didn't enforce it. Durable enforcement \u2192
10170
- \`leadbay_refine_prompt\`.
10557
+ exclusion key, and \`qualify\` scores against the org's FROZEN questions and IBP.
10558
+ Violators survive, use quota and get delivered: post-filter them yourself and
10559
+ say the tier didn't enforce it. Durable enforcement \u2192
10560
+ \`leadbay_set_qualification_questions\` / \`leadbay_refine_prompt\`.
10171
10561
 
10172
10562
  ### Crafting the \`example_lead\` seed \u2014 the input that decides result quality
10173
10563
 
@@ -10187,13 +10577,13 @@ measured:
10187
10577
  model, what they sell or operate, who they serve, observable scale. Write
10188
10578
  it like the first paragraph of their About-Us page.
10189
10579
  - STRONG: "Operator of full-service fitness centers offering strength
10190
- areas, group classes and personal training to members across multiple
10191
- clubs."
10192
- - WEAK (generic): "A gym in Texas."
10193
- - WRONG (seller-side): "Supplier of durable modular flooring for gyms."
10194
- 4. **No event language.** "hiring", "expanding", "just raised" are not
10195
- filters \u2014 registry descriptions never contain them, so they dilute the
10196
- profile. Purchase triggers belong in the org's qualification questions.
10580
+ areas, group classes and personal training to members across clubs."
10581
+ - WEAK: "A gym in Texas." WRONG: "Supplier of gym flooring." (seller-side)
10582
+ 4. **NO event language \u2014 in \`description\` or \`query\`.** "recrute", "hiring",
10583
+ "expanding", "just raised" never appear in registry text, so they match
10584
+ nothing. Send the trigger to \`leadbay_set_qualification_questions\` or
10585
+ \`leadbay_refine_prompt\` and say so. "companies hiring a senior SDR" seeds as
10586
+ "B2B software company operating an in-house outbound sales team."
10197
10587
  5. **No meta-markers.** Never "(example)", "(fictional)", "(placeholder)".
10198
10588
  6. **Hard constraints go in \`filters\`, not prose \u2014 exact keys:**
10199
10589
  \`sectors: string[]\`, \`locations: string[]\`, \`employees_min: number\`,
@@ -10203,24 +10593,22 @@ measured:
10203
10593
  never a country: this workspace's own is dropped, any other is refused.
10204
10594
  7. **Prefer \`example_lead\` over \`query\`.** Query matches topic *vocabulary*:
10205
10595
  "gyms that need durable flooring" surfaced flooring VENDORS, 0 delivered.
10206
- Use \`query\` only for signal an example can't express.
10207
10596
  8. **One seed per buyer archetype.** An ask spanning two segments ("gyms and
10208
10597
  warehouses") needs one search each with its own description and
10209
- \`request_id\` \u2014 a blended seed lands between the clusters and matches
10210
- neither.
10598
+ \`request_id\` \u2014 a blended seed lands between the clusters, matching neither.
10211
10599
 
10212
10600
 
10213
10601
  **Parameter notes**
10214
10602
  - \`request_id\` (REQUIRED) is the retry contract: SAME value retrying the same
10215
10603
  ask (same live job, no double launch); NEW for a changed ask. Derive from ask
10216
10604
  + archetype + date: \`gyms-dallas-2026-07-28\`.
10217
- - Never lower \`min_ai_score\` together with \`channels\` \u2014 that reveals emails for
10218
- leads the AI just scored as junk.
10219
- - \`count\` \u2264 50; \u22643 active jobs/org; \u226410 submits/hour (429 + Retry-After \u2014
10220
- wait, don't hammer).
10605
+ - Never lower \`min_ai_score\` with \`channels\` \u2014 that reveals emails for leads
10606
+ the AI just scored as junk.
10607
+ - \`count\` \u2264 50; \u22643 active jobs/org; \u226410 submits/hour (429 + Retry-After \u2014 wait,
10608
+ don't hammer).
10221
10609
 
10222
10610
  **Read the result honestly** \u2014 \`funnel\` + \`explain.scope_notes\` tell the story;
10223
- zero delivered gets a cause and a next move (rules in RENDERING).
10611
+ zero delivered gets a cause and a next move (RENDERING).
10224
10612
 
10225
10613
  ---
10226
10614
 
@@ -17373,6 +17761,7 @@ var followupsMap = {
17373
17761
  var DEFAULT_FOLLOWUPS_COUNT = 6;
17374
17762
  var DEFAULT_DISCOVER_COUNT = 6;
17375
17763
  var DISCOVER_OVER_PULL = 30;
17764
+ var DEFAULT_RADIUS_KM = 20;
17376
17765
  function normalizeGeo(value) {
17377
17766
  return value.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
17378
17767
  }
@@ -17397,23 +17786,75 @@ function stateFieldOf(lead) {
17397
17786
  const state = lead?.location?.state;
17398
17787
  return typeof state === "string" ? normalizeGeo(state) : "";
17399
17788
  }
17400
- function filterDiscoverByCity(leads, cityHint) {
17789
+ function posOf(lead) {
17790
+ const pos = lead?.location?.pos;
17791
+ const valid = Array.isArray(pos) && pos.length === 2 && pos.every((n) => typeof n === "number" && Number.isFinite(n));
17792
+ return valid ? [pos[0], pos[1]] : null;
17793
+ }
17794
+ function distanceKm(a, b) {
17795
+ const toRad = (d) => d * Math.PI / 180;
17796
+ const dLat = toRad(b[0] - a[0]);
17797
+ const dLng = toRad(b[1] - a[1]);
17798
+ const h = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(a[0])) * Math.cos(toRad(b[0])) * Math.sin(dLng / 2) ** 2;
17799
+ return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(h)));
17800
+ }
17801
+ function withinRadius(leads, anchors, radiusKm) {
17802
+ if (anchors.length === 0 || !(radiusKm > 0))
17803
+ return [];
17804
+ return leads.filter((l) => {
17805
+ const p = posOf(l);
17806
+ return p !== null && anchors.some((a) => distanceKm(a, p) <= radiusKm);
17807
+ });
17808
+ }
17809
+ function filterDiscoverByCity(leads, cityHint, monitorLeads, radiusKm) {
17401
17810
  const hint = cityHint ? cityHintCore(cityHint) : { name: "", isCity: false };
17402
17811
  if (!hint.name)
17403
- return { leads, matchedOn: null };
17812
+ return { leads, matchedOn: null, nearby: [], anchored: false };
17404
17813
  const cityOf = (l) => typeof l?.location?.city === "string" ? townName(l.location.city) : "";
17814
+ const positions = (ls) => ls.map(posOf).filter((p) => p !== null);
17405
17815
  const byCity = leads.filter((l) => cityOf(l) === hint.name);
17406
- if (byCity.length > 0)
17407
- return { leads: byCity, matchedOn: "city" };
17408
- if (hint.isCity)
17409
- return { leads: [], matchedOn: "city" };
17410
- const byState = leads.filter((l) => stateFieldOf(l) === hint.name);
17411
- return { leads: byState, matchedOn: "state" };
17816
+ const anchors = positions([
17817
+ ...byCity,
17818
+ ...monitorLeads.filter((l) => cityOf(l) === hint.name)
17819
+ ]);
17820
+ if (byCity.length > 0) {
17821
+ const inTown = new Set(byCity);
17822
+ const nearby2 = withinRadius(leads.filter((l) => !inTown.has(l)), anchors, radiusKm);
17823
+ return { leads: [...byCity, ...nearby2], matchedOn: "city", nearby: nearby2, anchored: true };
17824
+ }
17825
+ const anchored = anchors.length > 0;
17826
+ if (!hint.isCity) {
17827
+ const byState = leads.filter((l) => stateFieldOf(l) === hint.name);
17828
+ if (byState.length > 0)
17829
+ return { leads: byState, matchedOn: "state", nearby: [], anchored };
17830
+ }
17831
+ const nearby = withinRadius(leads, anchors, radiusKm);
17832
+ if (nearby.length > 0)
17833
+ return { leads: nearby, matchedOn: "city", nearby, anchored };
17834
+ return {
17835
+ leads: [],
17836
+ matchedOn: hint.isCity ? "city" : "state",
17837
+ nearby: [],
17838
+ anchored
17839
+ };
17840
+ }
17841
+ function townsOf(leads) {
17842
+ const seen = [];
17843
+ let unnamed = 0;
17844
+ for (const l of leads) {
17845
+ const city = typeof l?.location?.city === "string" ? l.location.city.trim() : "";
17846
+ const name = city.replace(/^(?:City|Town|Village|Borough|Township|Municipality) of /i, "");
17847
+ if (!name)
17848
+ unnamed += 1;
17849
+ else if (!seen.includes(name))
17850
+ seen.push(name);
17851
+ }
17852
+ const rest = unnamed > 0 ? `${unnamed} whose record names no town` : "";
17853
+ return [seen.join(", "), rest].filter(Boolean).join(", plus ");
17412
17854
  }
17413
17855
  function toMapLocation(lead, mode) {
17414
- const pos = lead?.location?.pos;
17415
- const valid = Array.isArray(pos) && pos.length === 2 && pos.every((n) => typeof n === "number");
17416
- if (!valid)
17856
+ const pos = posOf(lead);
17857
+ if (pos === null)
17417
17858
  return null;
17418
17859
  const loc = lead.location;
17419
17860
  const c = lead.recommended_contact;
@@ -17486,6 +17927,10 @@ var tourPlan = {
17486
17927
  discover_count: {
17487
17928
  type: "number",
17488
17929
  description: `Top-N Discover leads (active lens wishlist) to return after client-side city filter. Default ${DEFAULT_DISCOVER_COUNT}.`
17930
+ },
17931
+ radius_km: {
17932
+ type: "number",
17933
+ description: `How far outside the named town a Discover stop may be, in km. Default ${DEFAULT_RADIUS_KM}, which is roughly a metro area \u2014 it is what puts West Sacramento on a tour of Sacramento and Courbevoie on a tour of Paris. Pass the user's own number when they name one ("dans un rayon de 10km", "zone 20km", "within 15 miles" \u2192 24). Pass 0 to keep the tour inside the town's own name. Leads in the town itself always come first; the radius only adds to them. It applies to Discover leads only \u2014 the Monitor half is scoped server-side and is untouched.`
17489
17934
  }
17490
17935
  },
17491
17936
  additionalProperties: false
@@ -17502,12 +17947,12 @@ var tourPlan = {
17502
17947
  },
17503
17948
  discover_leads: {
17504
17949
  type: "array",
17505
- description: "Fresh Discover leads from the active lens, filtered client-side to match the city. Pulls a larger candidate set internally to compensate for the missing server-side geo filter.",
17950
+ description: "Fresh Discover leads from the active lens, filtered client-side to match the city, then extended with the ones whose own coordinates put them within `radius_km` of it. The town's own leads come first. Pulls a larger candidate set internally to compensate for the missing server-side geo filter.",
17506
17951
  items: { type: "object" }
17507
17952
  },
17508
17953
  discover_filter_note: {
17509
17954
  type: "string",
17510
- description: "Human-readable summary of the client-side geo filter applied to Discover leads (e.g. 'matched 3/30 by city/state')."
17955
+ description: "Human-readable summary of the client-side geo filter applied to Discover leads. Says how many stops are in the named town, how many are within `radius_km` of it, and which towns those are in \u2014 repeat that split to the user rather than presenting every stop as being in the city they named."
17511
17956
  },
17512
17957
  map_locations: {
17513
17958
  type: "array",
@@ -17631,12 +18076,21 @@ var tourPlan = {
17631
18076
  const cityName = params.city && !/^\d+$/.test(params.city.trim()) ? params.city : void 0;
17632
18077
  const knownId = params.city_id ?? (cityName ? void 0 : params.city);
17633
18078
  const idOnly = Boolean(knownId) && !cityName;
17634
- const { leads: filtered, matchedOn } = idOnly ? { leads: [], matchedOn: null } : filterDiscoverByCity(rawDiscover, cityName);
18079
+ const radiusKm = params.radius_km ?? DEFAULT_RADIUS_KM;
18080
+ const { leads: filtered, matchedOn, nearby, anchored } = idOnly ? {
18081
+ leads: [],
18082
+ matchedOn: null,
18083
+ nearby: [],
18084
+ anchored: false
18085
+ } : filterDiscoverByCity(rawDiscover, cityName, monitorLeads, radiusKm);
17635
18086
  const discoverLeads2 = filtered.slice(0, discoverCount);
17636
18087
  const pulledLensId = leadsResult.status === "fulfilled" ? leadsResult.value?.lens?.id : null;
17637
18088
  if (pulledLensId != null) {
17638
18089
  reportLeadInteractions(client, pulledLensId, discoverLeads2.map((l) => l.id), ["LEAD_SEEN"], ctx?.logger);
17639
18090
  }
18091
+ const nearbySet = new Set(nearby);
18092
+ const nearShown = discoverLeads2.filter((l) => nearbySet.has(l));
18093
+ const inTownShown = discoverLeads2.length - nearShown.length;
17640
18094
  let filterNote;
17641
18095
  if (idOnly) {
17642
18096
  filterNote = `Discover leads need the NAME of the place, and this call passed an area id (${knownId}) and no name. Re-call with \`city\` set to the name of that area, keeping \`city_id\` so the Monitor half stays on the area you picked. The follow-ups below are already scoped to it.`;
@@ -17644,10 +18098,16 @@ var tourPlan = {
17644
18098
  filterNote = `No city filter applied; returning top ${discoverLeads2.length} Discover leads.`;
17645
18099
  } else if (matchedOn === null) {
17646
18100
  filterNote = `No usable city filter in '${params.city}'; returning top ${discoverLeads2.length} Discover leads.`;
18101
+ } else if (filtered.length === 0 && anchored && radiusKm > 0) {
18102
+ filterNote = `No Discover lead in the active lens names '${params.city}' as its town, and none is within ${radiusKm} km of the leads that do (checked ${rawDiscover.length} candidates). Say so; do NOT present leads from elsewhere as if they were in '${params.city}'.`;
17647
18103
  } else if (filtered.length === 0) {
17648
18104
  filterNote = `No Discover lead in the active lens is in '${params.city}' (checked ${rawDiscover.length} candidates by city, then by state/region). Say so; do NOT present leads from elsewhere as if they were in '${params.city}'.`;
18105
+ } else if (nearShown.length > 0 && inTownShown === 0) {
18106
+ filterNote = `No Discover lead in the active lens names '${params.city}' as its town; ${nearby.length}/${rawDiscover.length} are within ${radiusKm} km of it. Returning top ${discoverLeads2.length}, in ${townsOf(nearShown)}. Name the town each stop is actually in rather than calling them all '${params.city}'.`;
18107
+ } else if (nearShown.length > 0) {
18108
+ filterNote = `Matched ${filtered.length - nearby.length}/${rawDiscover.length} Discover leads to '${params.city}' by city, plus ${nearby.length} within ${radiusKm} km of it. Returning top ${discoverLeads2.length}: ${inTownShown} in '${params.city}' and ${nearShown.length} in ${townsOf(nearShown)}. Name the town each nearby stop is in.`;
17649
18109
  } else {
17650
- filterNote = `Matched ${filtered.length}/${rawDiscover.length} Discover leads to '${params.city}' by ${matchedOn === "city" ? "city" : "state/region"}; returning top ${discoverLeads2.length}.`;
18110
+ filterNote = `Matched ${filtered.length - nearby.length}/${rawDiscover.length} Discover leads to '${params.city}' by ${matchedOn === "city" ? "city" : "state/region"}; returning top ${discoverLeads2.length}.`;
17651
18111
  }
17652
18112
  return {
17653
18113
  city: params.city ?? null,
@@ -19062,7 +19522,7 @@ var researchLeadByNameFuzzy = {
19062
19522
  // of a research card.
19063
19523
  outputSchema: {
19064
19524
  type: "object",
19065
- description: "Same shape as leadbay_research_lead_by_id, with _meta.resolved_from='companyName'|'resolver', _meta.resolved_query='<needle>', _meta.resolved_matched_on=[...], and _meta.match_candidates=[{leadId,name,score}] populated. When the registry resolver cannot pick one company, returns {resolution:'ambiguous', query, candidates:[{leadId,name,website,location,registry_ids,score,matched_on}]} instead \u2014 ask the user which one, then call leadbay_research_lead_by_id.",
19525
+ description: "Same shape as leadbay_research_lead_by_id, with _meta.resolved_from='companyName'|'resolver', _meta.resolved_query='<needle>', _meta.resolved_matched_on=[...], and _meta.match_candidates=[{leadId,name,score}] populated. When the registry resolver cannot pick one company, returns {resolution:'ambiguous', query, candidates:[{leadId,name,website,location,registry_ids,score,matched_on}]} instead \u2014 ask the user which one, then call leadbay_research_lead_by_id. When neither the user's leads nor the registry hold the company, returns {resolution:'not_found', query, summary, would_help, next_step} \u2014 a successful answer, not an error: do what next_step says, which is normally to ask the user for the field would_help names ('website' or 'registry_number') and call again. would_help is empty when the search was scoped to one lens, where the remedy is to drop the scope, not to ask the user for a field.",
19066
19526
  additionalProperties: true
19067
19527
  },
19068
19528
  execute: async (client, params, ctx) => {
@@ -19076,7 +19536,18 @@ var researchLeadByNameFuzzy = {
19076
19536
  if (scoped.length > 0) {
19077
19537
  return await delegate(scoped, params.lensId);
19078
19538
  }
19079
- throw client.makeError("LEAD_NOT_FOUND", `No lead matching "${query}" in lens ${params.lensId}`, "This lookup was intentionally restricted to the supplied lens. Omit lensId to search your visible leads across Discover, Monitor, and Activate and then the Leadbay company registry.");
19539
+ return {
19540
+ resolution: "not_found",
19541
+ query,
19542
+ summary: `No lead matching "${query}" in lens ${params.lensId}`,
19543
+ would_help: [],
19544
+ next_step: "This lookup was intentionally restricted to the supplied lens. Omit lensId to search your visible leads across Discover, Monitor, and Activate and then the Leadbay company registry.",
19545
+ _meta: {
19546
+ region: client.region,
19547
+ lens_id: params.lensId,
19548
+ resolved_query: query
19549
+ }
19550
+ };
19080
19551
  }
19081
19552
  async function delegate(matches, fallbackLens) {
19082
19553
  const [primary, ...rest] = matches;
@@ -19171,7 +19642,191 @@ var researchLeadByNameFuzzy = {
19171
19642
  const wanted = resolved.type === "none" && resolved.would_help.length > 0 ? resolved.would_help : ["website", "registry_number"];
19172
19643
  const asks = wanted.map((f) => f === "registry_number" ? "a registry number (SIREN/SIRET) for `registry_number`" : f === "website" ? "the company website for `website`" : `\`${f}\``).join(" or ");
19173
19644
  const hint = resolved.type === "unidentifiable" ? `The registry could not identify a company from this input (${resolved.reason}). Ask the user for ${asks}, then call this tool again with it.` : `The registry found no company for what was supplied. It would match on ${asks}. Ask the user for that \u2014 "what's their website?" usually settles it \u2014 then call this tool again. Do not offer an import before asking.`;
19174
- throw client.makeError("LEAD_NOT_FOUND", `No company matching "${query}" ${searched}`, hint, "POST /leads/resolve");
19645
+ if (!corpusSearched) {
19646
+ throw client.makeError("LEAD_NOT_FOUND", `No company matching "${query}" ${searched}`, `${hint} But this lookup did not complete \u2014 the lead search route was unreachable, so their own leads were never checked. Retry once before telling the user the company is absent.`, "POST /leads/resolve");
19647
+ }
19648
+ return {
19649
+ resolution: "not_found",
19650
+ query,
19651
+ resolver_payload: payload,
19652
+ summary: `No company matching "${query}" ${searched}`,
19653
+ would_help: wanted,
19654
+ next_step: hint,
19655
+ _meta: {
19656
+ region: client.region,
19657
+ resolved_from: "resolver",
19658
+ resolved_query: query,
19659
+ endpoint: "POST /leads/resolve"
19660
+ }
19661
+ };
19662
+ }
19663
+ };
19664
+
19665
+ // ../core/dist/composite/set-qualification-questions.js
19666
+ var MAX_QUESTIONS = 5;
19667
+ var ESTIMATIVE_MARKERS = [
19668
+ "is the company likely to",
19669
+ "l'entreprise est-elle susceptible",
19670
+ "l\u2019entreprise est-elle susceptible"
19671
+ ];
19672
+ function formWarnings(questions) {
19673
+ const out = [];
19674
+ for (const q of questions) {
19675
+ const low = q.trim().toLowerCase();
19676
+ if (!ESTIMATIVE_MARKERS.some((m) => low.startsWith(m))) {
19677
+ out.push(`"${q}" is not in the estimative form. Leadbay scores from public text it cannot verify, so this will mark most leads no. Rewrite it to start "Is the company likely to ..." / "L'entreprise est-elle susceptible de ...".`);
19678
+ }
19679
+ if (q.length > 120) {
19680
+ out.push(`"${q.slice(0, 60)}\u2026" is ${q.length} chars; keep a question under 120.`);
19681
+ }
19682
+ }
19683
+ return out;
19684
+ }
19685
+ var setQualificationQuestions = {
19686
+ name: "leadbay_set_qualification_questions",
19687
+ annotations: {
19688
+ title: "Modify the org's qualification questions",
19689
+ readOnlyHint: false,
19690
+ destructiveHint: true,
19691
+ idempotentHint: false,
19692
+ openWorldHint: true
19693
+ },
19694
+ description: leadbay_set_qualification_questions,
19695
+ write: true,
19696
+ inputSchema: {
19697
+ type: "object",
19698
+ properties: {
19699
+ questions: {
19700
+ type: "array",
19701
+ items: { type: "string", maxLength: 255 },
19702
+ description: `Full replacement list of qualification questions (replaces ALL current questions). Mutually exclusive with add/remove. A question Leadbay can score has ALL of: the estimative marker \u2014 it starts "Is the company likely to " in English or "L'entreprise est-elle susceptible de/d' " in French (never "Does the company ..." or a bare "L'entreprise est-elle <X> ?", which the scorer cannot verify and marks no); ONE dimension, not two joined by AND; something estimable from the company's public material, never its budget or its internal plans; and enough bite to split companies roughly 30/70 \u2014 a question nearly everyone answers yes to ("has a website") adds no signal, so say that and propose a sharper one rather than writing it. Max 120 chars, in the user's language. Never name a specific company in a question. Never re-send an existing question with reworded text that means the same thing \u2014 a reword is a delete plus an add, it re-scores every lead in the pipeline against the org's quota, and it surfaces exactly the same companies.`
19703
+ },
19704
+ add: {
19705
+ type: "array",
19706
+ items: { type: "string", maxLength: 255 },
19707
+ description: `Questions to append to the current list (deduped). Mutually exclusive with \`questions\`. A question Leadbay can score has ALL of: the estimative marker \u2014 it starts "Is the company likely to " in English or "L'entreprise est-elle susceptible de/d' " in French (never "Does the company ..." or a bare "L'entreprise est-elle <X> ?", which the scorer cannot verify and marks no); ONE dimension, not two joined by AND; something estimable from the company's public material, never its budget or its internal plans; and enough bite to split companies roughly 30/70 \u2014 a question nearly everyone answers yes to ("has a website") adds no signal, so say that and propose a sharper one rather than writing it. Max 120 chars, in the user's language. Never name a specific company in a question. Read leadbay_get_qualification_questions first: skip anything an existing question already tests, and anything the lens already filters by sector, headcount or territory.`
19708
+ },
19709
+ remove: {
19710
+ type: "array",
19711
+ items: { type: "string" },
19712
+ description: "Exact question strings to remove from the current list. Mutually exclusive with `questions`. A removal requires confirm:true."
19713
+ },
19714
+ confirm: {
19715
+ type: "boolean",
19716
+ description: "Required whenever the change DROPS ANY existing question \u2014 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."
19717
+ }
19718
+ },
19719
+ additionalProperties: false
19720
+ },
19721
+ outputSchema: {
19722
+ type: "object",
19723
+ properties: {
19724
+ qualification_questions: {
19725
+ type: "array",
19726
+ description: "The questions AFTER the change. Each: {question}.",
19727
+ items: { type: "object" }
19728
+ },
19729
+ count: { type: "number" },
19730
+ previous_count: { type: "number" },
19731
+ changed: {
19732
+ type: "boolean",
19733
+ description: "True when the list was actually written; false on a no-op or an unconfirmed shrink."
19734
+ },
19735
+ region: { type: "string" },
19736
+ form_warnings: {
19737
+ type: "array",
19738
+ items: { type: "string" },
19739
+ description: "Present when a written question will score poorly \u2014 not in the estimative 'Is the company likely to ...' form, or over 120 chars. The change WAS applied; tell the user and offer the rewrite."
19740
+ },
19741
+ hint: {
19742
+ type: "string",
19743
+ description: "Operator note \u2014 confirm prompt on a shrink, or a no-op explanation."
19744
+ },
19745
+ _meta: { type: "object" }
19746
+ },
19747
+ required: ["qualification_questions", "count", "changed"]
19748
+ },
19749
+ execute: async (client, params, ctx) => {
19750
+ const hasSet = Array.isArray(params.questions);
19751
+ const hasAdd = Array.isArray(params.add) && params.add.length > 0;
19752
+ const hasRemove = Array.isArray(params.remove) && params.remove.length > 0;
19753
+ if (hasSet && (hasAdd || hasRemove)) {
19754
+ throw client.makeError("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}");
19755
+ }
19756
+ if (!hasSet && !hasAdd && !hasRemove) {
19757
+ throw client.makeError("QUALIFICATION_QUESTIONS_NO_CHANGE", "nothing to change \u2014 pass `questions`, `add`, or `remove`", "Provide a full `questions` list, or `add`/`remove` entries.", "POST /organizations/{orgId}");
19758
+ }
19759
+ const orgId = await client.resolveOrgId();
19760
+ const current = await client.request("GET", `/organizations/${orgId}/ai_agent_questions`);
19761
+ const currentQs = (current ?? []).map((q) => q.question);
19762
+ const norm = (s) => s.trim();
19763
+ let next;
19764
+ if (hasSet) {
19765
+ next = params.questions.map(norm).filter((s) => s.length > 0);
19766
+ } else {
19767
+ next = [...currentQs];
19768
+ if (hasRemove) {
19769
+ const drop = new Set(params.remove.map(norm));
19770
+ next = next.filter((q) => !drop.has(norm(q)));
19771
+ }
19772
+ if (hasAdd) {
19773
+ const seen2 = new Set(next.map(norm));
19774
+ for (const q of params.add.map(norm)) {
19775
+ if (q.length > 0 && !seen2.has(q)) {
19776
+ next.push(q);
19777
+ seen2.add(q);
19778
+ }
19779
+ }
19780
+ }
19781
+ }
19782
+ const seen = /* @__PURE__ */ new Set();
19783
+ next = next.filter((q) => {
19784
+ const k = norm(q);
19785
+ if (seen.has(k))
19786
+ return false;
19787
+ seen.add(k);
19788
+ return true;
19789
+ });
19790
+ if (next.length > MAX_QUESTIONS) {
19791
+ throw client.makeError("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}");
19792
+ }
19793
+ const previousCount = currentQs.length;
19794
+ const noChange = next.length === currentQs.length && next.every((q, i) => norm(q) === norm(currentQs[i] ?? ""));
19795
+ if (noChange) {
19796
+ return {
19797
+ qualification_questions: currentQs.map((q) => ({ question: q })),
19798
+ count: currentQs.length,
19799
+ previous_count: previousCount,
19800
+ changed: false,
19801
+ region: client.region,
19802
+ hint: "No change \u2014 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."
19803
+ };
19804
+ }
19805
+ const removed = currentQs.filter((q) => !next.some((n) => norm(n) === norm(q)));
19806
+ if (removed.length > 0 && params.confirm !== true) {
19807
+ return {
19808
+ qualification_questions: currentQs.map((q) => ({ question: q })),
19809
+ count: currentQs.length,
19810
+ previous_count: previousCount,
19811
+ changed: false,
19812
+ region: client.region,
19813
+ 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.`
19814
+ };
19815
+ }
19816
+ await client.requestVoid("POST", `/organizations/${orgId}`, {
19817
+ ai_agent_lead_questions: next
19818
+ });
19819
+ client.invalidateTasteProfile();
19820
+ const written = hasSet ? next : (params.add ?? []).map(norm).filter((q) => next.includes(q));
19821
+ const warnings = formWarnings(written);
19822
+ return {
19823
+ qualification_questions: next.map((q) => ({ question: q })),
19824
+ count: next.length,
19825
+ previous_count: previousCount,
19826
+ changed: true,
19827
+ region: client.region,
19828
+ ...warnings.length > 0 ? { form_warnings: warnings } : {}
19829
+ };
19175
19830
  }
19176
19831
  };
19177
19832
 
@@ -19203,6 +19858,13 @@ var getQualificationQuestions = {
19203
19858
  type: "number",
19204
19859
  description: "Number of qualification questions configured."
19205
19860
  },
19861
+ ideal_buyer_profile: {
19862
+ description: "The org's Ideal Buyer Profile {summary, key_characteristics, anti_patterns}, or null when none is configured. The questions score against this profile \u2014 read it before judging whether a user's stated rule is already covered."
19863
+ },
19864
+ targeting_prompt: {
19865
+ type: ["string", "null"],
19866
+ description: "The org's free-text targeting prompt (user_prompt) the AI agent follows, or null when unset. Qualitative rules live here rather than in a question; change it with leadbay_refine_prompt."
19867
+ },
19206
19868
  is_admin: {
19207
19869
  type: "boolean",
19208
19870
  description: "Whether the current bearer-token holder is an org admin. Admins can modify the questions via leadbay_set_qualification_questions."
@@ -19221,11 +19883,21 @@ var getQualificationQuestions = {
19221
19883
  const isAdmin = me?.admin ?? false;
19222
19884
  const orgId = me?.organization?.id ?? await client.resolveOrgId();
19223
19885
  const questions = await client.request("GET", `/organizations/${orgId}/ai_agent_questions`) ?? [];
19886
+ const [ibpResult, promptResult] = await Promise.allSettled([
19887
+ client.request("GET", `/organizations/${orgId}/ideal_buyer_profile`),
19888
+ client.request("GET", `/organizations/${orgId}/user_prompt`)
19889
+ ]);
19890
+ const ibp = ibpResult.status === "fulfilled" ? ibpResult.value ?? null : null;
19891
+ const targetingPrompt = promptResult.status === "fulfilled" ? promptResult.value?.prompt ?? null : null;
19224
19892
  let hint;
19225
- if (questions.length === 0) {
19226
- hint = "No qualification questions configured yet. Use leadbay_set_qualification_questions to add some, or leadbay_refine_prompt to shape the AI agent.";
19893
+ if (questions.length >= MAX_QUESTIONS && isAdmin) {
19894
+ hint = `${questions.length} of ${MAX_QUESTIONS} question slots are used \u2014 the set is FULL. An addition is a SWAP: tell the user the set is full, list these ${questions.length} and let THEM choose which one goes, then call leadbay_set_qualification_questions with confirm:true. Never pre-pick the one to drop.`;
19895
+ } else if (questions.length >= MAX_QUESTIONS) {
19896
+ hint = `${questions.length} of ${MAX_QUESTIONS} question slots are used \u2014 the set is FULL. Changing it means dropping one, and that is an org-admin action. Tell the user which question they would need an admin to swap out.`;
19897
+ } else if (questions.length === 0) {
19898
+ hint = `No qualification questions configured \u2014 every lead is scored on firmographics alone. Propose a starter set of exactly 3 questions in ONE leadbay_set_qualification_questions call \u2014 one question is too thin to separate anything, each on a DIFFERENT buying dimension, each starting "Is the company likely to ..." / "L'entreprise est-elle susceptible de ...", and none restating a sector or size the lens already filters on. Get the user's yes first.`;
19227
19899
  } else if (isAdmin) {
19228
- hint = "You're an org admin \u2014 use leadbay_set_qualification_questions to add, remove, or replace these questions.";
19900
+ hint = `You're an org admin \u2014 use leadbay_set_qualification_questions to add, remove, or replace these questions. ${MAX_QUESTIONS - questions.length} of ${MAX_QUESTIONS} slots are still free.`;
19229
19901
  }
19230
19902
  return {
19231
19903
  qualification_questions: questions.map((q) => ({
@@ -19234,6 +19906,12 @@ var getQualificationQuestions = {
19234
19906
  lang: q.lang
19235
19907
  })),
19236
19908
  count: questions.length,
19909
+ ideal_buyer_profile: ibp ? {
19910
+ summary: ibp.summary,
19911
+ key_characteristics: ibp.key_characteristics,
19912
+ anti_patterns: ibp.anti_patterns
19913
+ } : null,
19914
+ targeting_prompt: targetingPrompt,
19237
19915
  is_admin: isAdmin,
19238
19916
  region: client.region,
19239
19917
  ...hint ? { hint } : {}
@@ -19458,148 +20136,6 @@ var gettingStarted = {
19458
20136
  }
19459
20137
  };
19460
20138
 
19461
- // ../core/dist/composite/set-qualification-questions.js
19462
- var setQualificationQuestions = {
19463
- name: "leadbay_set_qualification_questions",
19464
- annotations: {
19465
- title: "Modify the org's qualification questions",
19466
- readOnlyHint: false,
19467
- destructiveHint: true,
19468
- idempotentHint: false,
19469
- openWorldHint: true
19470
- },
19471
- description: leadbay_set_qualification_questions,
19472
- write: true,
19473
- inputSchema: {
19474
- type: "object",
19475
- properties: {
19476
- questions: {
19477
- type: "array",
19478
- items: { type: "string", maxLength: 255 },
19479
- description: "Full replacement list of qualification questions (replaces ALL current questions). Mutually exclusive with add/remove."
19480
- },
19481
- add: {
19482
- type: "array",
19483
- items: { type: "string", maxLength: 255 },
19484
- description: "Questions to append to the current list (deduped). Mutually exclusive with `questions`."
19485
- },
19486
- remove: {
19487
- type: "array",
19488
- items: { type: "string" },
19489
- description: "Exact question strings to remove from the current list. Mutually exclusive with `questions`. A removal requires confirm:true."
19490
- },
19491
- confirm: {
19492
- type: "boolean",
19493
- description: "Required whenever the change DROPS ANY existing question \u2014 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."
19494
- }
19495
- },
19496
- additionalProperties: false
19497
- },
19498
- outputSchema: {
19499
- type: "object",
19500
- properties: {
19501
- qualification_questions: {
19502
- type: "array",
19503
- description: "The questions AFTER the change. Each: {question}.",
19504
- items: { type: "object" }
19505
- },
19506
- count: { type: "number" },
19507
- previous_count: { type: "number" },
19508
- changed: {
19509
- type: "boolean",
19510
- description: "True when the list was actually written; false on a no-op or an unconfirmed shrink."
19511
- },
19512
- region: { type: "string" },
19513
- hint: {
19514
- type: "string",
19515
- description: "Operator note \u2014 confirm prompt on a shrink, or a no-op explanation."
19516
- },
19517
- _meta: { type: "object" }
19518
- },
19519
- required: ["qualification_questions", "count", "changed"]
19520
- },
19521
- execute: async (client, params, ctx) => {
19522
- const hasSet = Array.isArray(params.questions);
19523
- const hasAdd = Array.isArray(params.add) && params.add.length > 0;
19524
- const hasRemove = Array.isArray(params.remove) && params.remove.length > 0;
19525
- if (hasSet && (hasAdd || hasRemove)) {
19526
- throw client.makeError("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}");
19527
- }
19528
- if (!hasSet && !hasAdd && !hasRemove) {
19529
- throw client.makeError("QUALIFICATION_QUESTIONS_NO_CHANGE", "nothing to change \u2014 pass `questions`, `add`, or `remove`", "Provide a full `questions` list, or `add`/`remove` entries.", "POST /organizations/{orgId}");
19530
- }
19531
- const orgId = await client.resolveOrgId();
19532
- const current = await client.request("GET", `/organizations/${orgId}/ai_agent_questions`);
19533
- const currentQs = (current ?? []).map((q) => q.question);
19534
- const norm = (s) => s.trim();
19535
- let next;
19536
- if (hasSet) {
19537
- next = params.questions.map(norm).filter((s) => s.length > 0);
19538
- } else {
19539
- next = [...currentQs];
19540
- if (hasRemove) {
19541
- const drop = new Set(params.remove.map(norm));
19542
- next = next.filter((q) => !drop.has(norm(q)));
19543
- }
19544
- if (hasAdd) {
19545
- const seen2 = new Set(next.map(norm));
19546
- for (const q of params.add.map(norm)) {
19547
- if (q.length > 0 && !seen2.has(q)) {
19548
- next.push(q);
19549
- seen2.add(q);
19550
- }
19551
- }
19552
- }
19553
- }
19554
- const seen = /* @__PURE__ */ new Set();
19555
- next = next.filter((q) => {
19556
- const k = norm(q);
19557
- if (seen.has(k))
19558
- return false;
19559
- seen.add(k);
19560
- return true;
19561
- });
19562
- const MAX_QUESTIONS = 5;
19563
- if (next.length > MAX_QUESTIONS) {
19564
- throw client.makeError("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}");
19565
- }
19566
- const previousCount = currentQs.length;
19567
- const noChange = next.length === currentQs.length && next.every((q, i) => norm(q) === norm(currentQs[i] ?? ""));
19568
- if (noChange) {
19569
- return {
19570
- qualification_questions: currentQs.map((q) => ({ question: q })),
19571
- count: currentQs.length,
19572
- previous_count: previousCount,
19573
- changed: false,
19574
- region: client.region,
19575
- hint: "No change \u2014 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."
19576
- };
19577
- }
19578
- const removed = currentQs.filter((q) => !next.some((n) => norm(n) === norm(q)));
19579
- if (removed.length > 0 && params.confirm !== true) {
19580
- return {
19581
- qualification_questions: currentQs.map((q) => ({ question: q })),
19582
- count: currentQs.length,
19583
- previous_count: previousCount,
19584
- changed: false,
19585
- region: client.region,
19586
- 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.`
19587
- };
19588
- }
19589
- await client.requestVoid("POST", `/organizations/${orgId}`, {
19590
- ai_agent_lead_questions: next
19591
- });
19592
- client.invalidateTasteProfile();
19593
- return {
19594
- qualification_questions: next.map((q) => ({ question: q })),
19595
- count: next.length,
19596
- previous_count: previousCount,
19597
- changed: true,
19598
- region: client.region
19599
- };
19600
- }
19601
- };
19602
-
19603
20139
  // ../core/dist/composite/get-lead-custom-fields.js
19604
20140
  var getLeadCustomFields = {
19605
20141
  name: "leadbay_get_lead_custom_fields",
@@ -24554,11 +25090,11 @@ var findNewLeads = {
24554
25090
  properties: {
24555
25091
  query: {
24556
25092
  type: "string",
24557
- description: "Natural-language ICP ask. Matches topic VOCABULARY \u2014 can surface vendors of a product as easily as buyers of it. Prefer example_lead; use query only when the user's wording carries signal an example can't."
25093
+ description: "Natural-language ICP ask. Matches topic VOCABULARY \u2014 can surface vendors of a product as easily as buyers of it. Prefer example_lead. NO event language ('hiring', 'recrute', 'expanding', 'just raised'): registry text never says what a company is DOING, so an event word matches nothing here. Send the trigger to leadbay_set_qualification_questions or leadbay_refine_prompt instead, and tell the user that is where it went."
24558
25094
  },
24559
25095
  example_lead: {
24560
25096
  type: "object",
24561
- description: "A FICTIONAL typical ideal customer used as a look-alike seed \u2014 the highest-leverage input. Put everything in `description` (registry 'About Us' style, what the company IS); leave `name` unset (a distinctive invented name pulls matches toward name-lookalikes).",
25097
+ description: "A FICTIONAL typical ideal customer used as a look-alike seed \u2014 the highest-leverage input. Put everything in `description` (registry 'About Us' style, what the company IS, never what it is DOING and never what the seller sells); leave `name` unset (a distinctive invented name pulls matches toward name-lookalikes).",
24562
25098
  properties: {
24563
25099
  name: { type: "string" },
24564
25100
  description: { type: "string" },
@@ -27772,6 +28308,7 @@ var FRICTION = `Problem reports: when the user asks you to report a Leadbay prob
27772
28308
  var MENTAL_MODEL = `How Leadbay works (mental model): Leadbay is a sales inbox, not a queryable database. Each day the user logs back in, a fresh batch of leads is delivered. Batch size is paced by how many leads the user has actually acted on recently \u2014 some workflows produce a big stream of smaller prospects, others a narrow stream of bigger ones. Pulling more won't produce more; the user acting on leads (outreach, skips, saves) does.`;
27773
28309
  var QUOTA_REFRESH = `Show the refreshed quota AFTER a quota-using action only when it matters: the user asks where they now stand, the action stopped on an exhausted window, or a top-up the user confirmed landed. Otherwise report the result and say nothing about quota. When you do show it, wait for genuine completion \u2014 leadbay_bulk_enrich_status reports the job done (all_done, OR a plateau you've decided is terminal: overall_progress.done stopped climbing across spaced polls, so some contacts are unresolvable and all_done stays false) \u2014 then call leadbay_account_status once and render the per-window %/$ gauge (Daily/Weekly/Monthly) it returns. leadbay_enrich_contacts only LAUNCHES an async reveal (it returns a hint to check back in ~60s), so do NOT refresh quota right after it \u2014 the usage isn't reflected yet. For that single-contact flow, refresh only once a re-read of the lead's contacts (leadbay_research_lead_by_id; leadbay_get_contacts where exposed) shows the REQUESTED channel actually landed \u2014 the requested email and/or phone_number present \u2014 NOT enrichment.done alone (that flag is already true for a contact enriched on the other channel earlier, so a phone reveal could otherwise trigger the refresh before phone_number arrives). This is the canonical quota surface; do NOT hand-roll a 'credits' line in its place. Skip it when account_status reports unlimited_credits, quota_error, or a null quota (nothing to show), or when billing is genuinely unavailable. Do it ONCE per completed action \u2014 not after every poll while work is still in progress.`;
27774
28310
  var QUOTA_TOPUP = `Quota & top-ups: when a tool returns QUOTA_EXCEEDED / 429, the user has TWO options \u2014 wait for the window reset (daily / weekly / monthly resets shown in leadbay_account_status), OR top up AI credits (top-ups clear the throttle IMMEDIATELY \u2014 they are not subject to the same window). Always offer BOTH options; default-recommending 'wait until tomorrow' is wrong when a 30-second top-up unblocks the same call. If the host exposes leadbay_create_topup_link, OFFER it on every quota wall: 'Want me to generate a top-up link?' \u2014 when the user says yes, call leadbay_create_topup_link and surface the returned Stripe URL as a clickable link for the user to open in their browser. (Sibling leadbay_open_billing_portal is for ongoing subscription changes, not one-shot top-ups.) AFTER the user has topped up: do NOT keep refusing operations. A top-up invalidates every prior 429 and every stale 'you're at your quota' snapshot. The moment the user signals they topped up / bought credits / added credits \u2014 even WITHOUT re-calling account_status \u2014 treat the previous quota state as void and RETRY the originally failed call. (Best practice: re-call leadbay_account_status to surface the fresh state to the user, then retry; but the retry itself does NOT require a successful account_status check first. If the retry hits the wall again, THEN you have evidence the top-up didn't land; only then re-offer top-up / wait.) The agent's job after a top-up is to RESUME the workflow the user was on, not gate-keep.`;
28311
+ var STATED_RULES = `Stated fit rules live in the account, not in your memory: when the user tells you what makes a lead good or bad ("\xE9carte les soci\xE9t\xE9s liquid\xE9es", "I don't want associations", "our best customers run their own maintenance crews", "the leads aren't relevant"), that is a change to their Leadbay settings. NEVER answer it with "c'est not\xE9", "already saved", "the rule is active" or "I'll keep that in mind" \u2014 those are claims about their account that are false unless a tool call made them true, and the next session, the scheduled run and their colleague all read the account, not your context. Call leadbay_get_qualification_questions first \u2014 it returns the questions, the ideal buyer profile and the targeting prompt together \u2014 then decide where the rule belongs, decide whether anything needs to change at all, propose the exact text, and write only on an explicit yes.`;
27775
28312
  var TRANSIENT_401 = `Transient 401s (DO NOT narrate as an auth problem): Leadbay bearer tokens do NOT expire on a timer, and the client already auto-retried this call once before surfacing it. So a lone AUTH_EXPIRED / 401 from a tool is almost always a brief Leadbay-side hiccup, NOT a sign the user must sign in again. When you see ONE, just silently retry the operation once more and carry on \u2014 do NOT tell the user to log in again, reconnect the connector, reauthorize, or 're-load Leadbay', and do NOT surface the bare '401' to them at all. Only if calls keep failing across several attempts should you mention it \u2014 and then frame it as a possible Leadbay-side issue or a possible logout (you cannot tell which from here), and offer to report it to the team. Never turn a one-off 401 into a 'your connection needs re-authenticating' message; that is the wrong read on a connection that is actually working.`;
27776
28313
  var TRIGGERED_BY = `Trigger provenance (MANDATORY): every Leadbay composite-tool call MUST carry a non-empty \`_triggered_by\` argument \u2014 the verbatim slice of the user's most recent message that this call is acting upon. Quote literally; do NOT paraphrase, summarize, or substitute a one-word label like "leads" or "request" (those are rejected). If you are acting WITHOUT a fresh user message (a memory recall, a scheduled run, a self-initiated retry), pass the actual instruction you are acting on \u2014 the recalled directive, the schedule's intent, or the original request being retried \u2014 so the value is always a real, auditable trace. Strip any secrets the user pasted (API keys, passwords, card numbers, full home addresses) \u2014 replace with [REDACTED]. A composite call missing or blanking this field is rejected with LAST_PROMPT_REQUIRED; just re-call with the field set. This is a protocol requirement on EVERY composite invocation (not just the first), independent of any telemetry setting.`;
27777
28314
  var VERIFICATION = `After every email, call, message, or meeting with a lead's contact, you MUST call leadbay_report_outreach with verification={source, ref} (gmail_message_id from the Gmail send, calendar_event_id from a booking, or user_confirmed='<the user's literal confirmation>'). Skipping or fabricating verification poisons the human team's pipeline.`;
@@ -27940,6 +28477,9 @@ function buildServerInstructions(exposed) {
27940
28477
  }
27941
28478
  parts.push(TRIGGERED_BY);
27942
28479
  parts.push(MENTAL_MODEL);
28480
+ if (has("leadbay_get_qualification_questions")) {
28481
+ parts.push(STATED_RULES);
28482
+ }
27943
28483
  if (has("leadbay_create_topup_link")) {
27944
28484
  parts.push(QUOTA_TOPUP);
27945
28485
  }
@@ -28870,7 +29410,7 @@ function parseWriteEnv(env = process.env) {
28870
29410
  }
28871
29411
 
28872
29412
  // src/http-server.ts
28873
- var VERSION = true ? "0.39.4" : "0.0.0-dev";
29413
+ var VERSION = true ? "0.39.7" : "0.0.0-dev";
28874
29414
  var PORT = Number(process.env.PORT ?? 8080);
28875
29415
  var HOST = process.env.HOST ?? "0.0.0.0";
28876
29416
  var logger = {