# Changelog — @leadbay/mcp

## 0.46.1 — 2026-10-24 — ChatGPT gets a summary of the request and no Claude layout instructions

- **On ChatGPT, Leadbay no longer asks for the user's own words.** Each call
  there carries one sentence saying what the user wants, in the third person,
  with people's names replaced by their role. A call without it still runs.
  OpenAI's review scan flagged the verbatim copy on 39 tools as asking for
  more than the tool needs. Claude and every other assistant keep sending
  the user's words, as before.
- **On ChatGPT, tool descriptions leave out the layout instructions written
  for Claude.** The rendering recipes, the NEXT STEPS tables and the widget
  table are deleted from the ChatGPT descriptions, which are now about 40%
  shorter. ChatGPT's own check flagged them on every Leadbay call that asks
  for approval. Claude and every other assistant read exactly what they read
  before.
- **The ChatGPT submission is a plugin now.** `chatgpt-plugin/` holds what is
  uploaded to OpenAI: the listing, the review test cases, the logos and the
  connector URL. It replaces `chatgpt-app-submission.json`.

## 0.46.0 — 2026-10-14 — a welcome board, boards that load without a blank wait, and a clearer lead card

- **A welcome board for new users.** On a first visit, or when you ask what
  Leadbay can do, the assistant shows a board that greets you by name, shows
  what Leadbay's AI learned about your business (intent signals,
  qualification questions, your ideal buyer profile) and lists eight things
  you can ask, each with a button that sends or copies the phrase. If your
  account can't be read, it says so and offers Try again.
- **The boards load without a blank wait.** While leads or your account load,
  the boards show grey placeholders in the shape of what is coming, and the
  real content takes their place where they stood. Each board appears piece
  by piece in reading order; with reduced motion turned on, it simply fades
  in.
- **A clearer triage board.** The lead count sits next to the title, the
  filters fit on one line, and the status picker and "Apply to selection"
  appear only once you have ticked leads. Pages are numbered at the bottom
  right, and changing page brings you back to the top.
- **A clearer lead card.** The recommended contact and the company's own
  phone, email and website are now separate, so the switchboard is never
  mistaken for the contact's direct line. The company's social profiles show
  as icons, the contact's name carries the LinkedIn mark, and a ticked
  prospecting action shows a ✓, not just a colour. "Save note" is on one line
  with its field, and a long qualifier answer has "Show more".
- **The route planner, fixed.** On a computer in dark mode the panel no
  longer shows white text on white. "All areas" no longer shows before a city
  is picked, the "Loading follow-ups…" label no longer stays on the map, and
  the map fills its space from the first moment. Reordering your stops slides
  them into place, and a removed stop fades out.
- **The boards fit a phone screen and work better with a keyboard and a
  screen reader.** Nothing runs off a 320px screen, focus stays on the button
  you pressed, and loading, filtering and errors are announced.

## 0.45.0 — 2026-10-08 — the route planner in ChatGPT, boards that load once, and boards in French

- **The route planner opens in the chat outside Claude.** On ChatGPT and other
  assistants that can display it, "I'm going to Lyon — who should I visit?"
  now shows the route planner: your follow-ups on a map of your country, a
  panel to set a status, tick a prospecting action and log what happened, and
  a day's route to drive in Google Maps. It opens on the city you named. In
  Claude nothing changes: the assistant keeps its own map.
- **After a trip plan, "Route planner" opens the map in ChatGPT too.** In
  ChatGPT and other assistants, picking the suggested next step after "I'm in
  Lyon on Thursday, who should I meet?" now opens the route planner on Lyon.
  It used to ask for a Claude artifact, which those assistants cannot make, so
  nothing opened. Claude keeps its own way of opening it.
- **The route planner works in cowork too.** It used to reach Leadbay only as
  a claude.ai artifact, and showed "Leadbay isn't available here" anywhere
  else.
- **The boards don't load the same leads twice.** In ChatGPT and other
  assistants, the board now shows the leads the assistant just pulled instead
  of pulling them again, and "next page" continues from there, on the same
  lens.
- **The boards speak French.** The triage board and the route planner show
  their buttons, filters, statuses and messages in French when your assistant
  or browser is set to French, everywhere they open. Lead data is shown as
  it is in Leadbay. A note logged from the route planner names its channel in
  your language ("Appel: …").

## 0.44.1 — 2026-10-08 — the board in dark mode, and a contact's LinkedIn one click away

- **The board follows the assistant's light or dark theme, all of it.** In
  ChatGPT's dark mode the cards turned dark but the page behind them stayed
  light grey, so "Today's leads" and its subtitle read white on grey. The whole
  page now switches together, and it follows a theme change made while the
  board is open. In Claude the board stays light, as before.
- **The contact's name opens their LinkedIn.** On the triage board and in the
  route planner's lead panel, the recommended contact's name now links to
  their LinkedIn profile, as it already did in the assistant's table. When no
  profile is on file it opens a LinkedIn search for that person at that
  company, and its tooltip says so. This applies everywhere the boards open:
  ChatGPT, other assistants, and Claude.

## 0.44.0 — 2026-10-08 — the triage board, inside ChatGPT and other assistants

- **Today's leads open as the Lead Triage Board right in the chat, outside
  Claude.** On ChatGPT and on assistants that can display MCP Apps (GitHub
  Copilot in VS Code, Goose, …), "show me today's leads" now shows the same
  board Claude users get as an artifact: every lead with its fit bar, why it
  fits and the contact, plus like, dislike, status, prospecting action, note
  and requalify. Each click saves straight away. An assistant that cannot
  display it shows the usual results.
- **Claude does not change.** On `https://mcp.leadbay.app/mcp` (and the older
  `/fr/mcp`) Claude keeps its own map, drafting and choice widgets and its
  board artifacts.
- **One connector URL per assistant.** Claude: `/mcp`. ChatGPT:
  `/chatgpt/mcp`. Every other assistant: the new
  `https://mcp.leadbay.app/apps/mcp`, also the address the MCP Registry now
  gives GitHub Copilot's one-click install. All three work for US and French
  accounts. An assistant already connected to `/mcp` keeps working; switching
  its URL (and signing in once more) is what brings the board.
- **No duplicate list under the board.** Where the board shows, the assistant
  now sums the batch up in a sentence instead of drawing the same leads again
  as a table.

## 0.43.1 — 2026-10-07 — a job's own spend cap no longer claims to cover any job

- **The search's spend cap stops promising it is enough.** The parameter that
  bounds what one lead search may spend used to tell the assistant its default
  "covers any job". It does not: your plan has its own daily allowance, it is
  far smaller, and it stops a search whatever that parameter is set to. The
  parameter now says what it bounds and names your plan's allowance as the
  separate limit. Reported after a paying account spent forty minutes on
  searches that kept running out (product#4250).

## 0.43.0 — 2026-09-30 — a misnamed argument is read or named, never dropped

- **`lead_id` and `leadId` both work.** Leadbay tools spell the lead id
  differently: researching a lead and adding a note take `leadId`, setting a
  CRM status takes `lead_ids`. An assistant that used the other spelling was
  told the id was missing, and a scheduled run lost a night's notes and
  statuses this way. An argument that differs only by capitals or underscores
  is now read as the one the tool expects, and setting a status also takes a
  single lead id.
- **Any other unknown argument is named.** It used to be dropped silently. It
  now comes back as an error that names it, suggests the argument it probably
  meant, and lists the ones the tool takes. A call rejected this way writes
  nothing.
- **Team prospecting setup names the right arguments.** It told the assistant
  to pass `user_prompt` to two tools that take `prompt` and `base`.

## 0.42.0 — 2026-09-28 — every rep gets the same boards

- **The route planner and the lead triage board are now finished pages.**
  Asking for either, or picking it from the suggested next steps, gives every
  rep exactly the same board, in the same layout and colours, rather than a
  page the assistant rebuilds each time. Each page loads its own leads when it
  opens.
- **The route planner shows your country.** A US workspace gets a map of the
  United States, the lower 48 in view, instead of France. French workspaces
  keep the map of France, départements and islands included.
- **The map opens on the whole country, filling the panel.** It used to open
  zoomed out, with the country small in a wide margin. It now fits the
  country to the space, and keeps fitting it while the page settles, until you
  move the map yourself.
- **Planning a trip to a city opens the planner on that city.** After "I'm in
  Lyon on Thursday", the planner opens with Lyon already filtered.

## 0.41.1 — 2026-09-26 — triage boards stop drawing a second outcome control

- **Triage boards are built with the same outreach controls as the web app.**
  The template the assistant copies for a triage board still drew an old
  "Outreach result" menu next to the prospecting-action buttons — two controls
  changing the same thing. New boards now get only the four prospecting
  actions and a note, and the note saves straight away.

## 0.41.0 — 2026-09-25 — boards a rep can work from: a lead desk, a route planner, and buttons that answer

- **Every Leadbay board is built from the same kit.** Asking for a triage board,
  a call board or a coverage board now always produces one built on Leadbay's
  own components, in the product's colours and on a light page — not a page the
  assistant styles from scratch. Every lead card carries a **Qualify /
  Requalify** button.
- **A lead desk for working a list.** After a follow-up list or a campaign call
  sheet, the assistant offers a *Contact and outreach* board: each lead's phone,
  email and LinkedIn in one row, with the company context, and an **Enrich**
  button on the contacts that have no way to reach them yet.
- **A route planner for a day on the road.** "I'm driving to Lyon on Thursday"
  now offers a board that is half map, half panel: click a pin to open the lead,
  set its status, record how the visit went, and see the pin change as you work.
  Leads with no address are listed beside the map instead of disappearing. The
  map opens on France, and **Fit to leads** frames yours.
- **Two new guided sessions.** *Outreach session* runs a working session over
  any list — follow-ups, a lens or a campaign — and checks first whether the
  leads can actually be reached. *Route session* plans the trip. Both record
  each outcome as you tell it.
- **Coverage over more than sectors.** The coverage board can measure the
  portfolio by any dimension, and a new reachability view shows how many leads
  have a phone, an email, both, or neither.
- **Prospecting actions work the way they do in the web app.** On a board you
  can tick and untick *Still chasing*, *Meeting planned*, *Could not reach* and
  *Not interested*, several at once, in the web app's colours. What is ticked
  is what you ticked today, and unticking one takes it off without leaving a
  note behind.
- **A board's buttons answer in a second, not a minute.** Logging outreach or
  setting a prospecting action from a board used to wait 60 seconds and then
  report that Leadbay "took too long" — even though the change had been saved.
  Following that advice logged the same visit twice. Boards now save a note
  and a prospecting action the way the web app does, so they confirm straight
  away. Outreach logged by the assistant itself still asks you to confirm it,
  as before.

## 0.40.0 — 2026-09-22 — every tool says plainly what it does and what it changes

- **Each tool now says what it does before it says when to use it.** A tool's
  description used to open with the phrases that trigger it. It opens with one
  or two sentences on what the tool does, so both the assistant and a reviewer
  read the purpose first.
- **Every tool declares honestly whether it only reads.** Pulling leads,
  researching a company and opening a follow-up list all write something small
  back to your account — they record which leads you were shown so the same
  ones stop coming back tomorrow. They used to be advertised as pure reads.
  They are now declared as actions, so your assistant can tell you before it
  runs one.
- **Only the tools that really reach outside your account say so.** Everything
  that just talks to your own Leadbay workspace no longer claims to reach the
  open internet. The fourteen that genuinely do — finding new companies,
  qualifying them, buying contact details, sending feedback — say so, and each
  one now carries a written reason.
- **Six tools were renamed to say what they do.** "my lenses" also switches,
  renames and deletes a view, so it is now "manage lenses". "refine prompt"
  edits what your account looks for, so it is now "refine lead targeting". The
  other four are "list previously enriched titles", "list lens seed
  candidates", "get artifact runtime" and "report artifact error".
- **Wording that ranked one tool above another is gone.** Tool descriptions no
  longer say one surface beats another or that one path is preferred.

## 0.39.0 — 2026-09-15

Two changes merged since 0.38.2 (#231, #232), both product#4131.

- **A list of companies is one `leadbay_qualify_leads` call** (#231). "For each
  of these companies" routed to `leadbay_research_lead_by_name_fuzzy`, once per
  company: a first-time FR user sent 4,026 such calls in 84 minutes on
  2026-09-14 and never came back. A list now goes to `leadbay_qualify_leads`
  with `qualify: false`, which is free and takes 500 names per call. That free
  identity pass answers with one row per company, in the user's order, with the
  website and the company LinkedIn, plus counts of what Leadbay found and what
  it lacks. The same 82 broker names on FR prod went from 269,542 to 20,846
  characters. Past 100 rows, `leadbay_lead_job_status(compact: true, offset)`
  pages the rest. Web research on the companies with no website is the last
  NEXT STEPS row, behind the existing quote-then-confirm gate.
- **A local install saves the full list as a CSV** (#232). `bin.ts` passes a
  `saveFile` capability through `ToolContext`; the hosted server does not pass
  it, so it writes no file. The CSV lands in `~/Downloads`, or `~` when there
  is no Downloads folder, and its path comes back in `file`.

## 0.38.2 — 2026-09-14

Four fixes merged since 0.38.1 (#224, #227, #228, #229).

- **Quota shows only when it matters** (#224). `leadbay_account_status`
  answers a plain "what account am I connected to?" with user and org. The
  quota gauge appears when the user asks about quota, usage or account status,
  or when a window is exhausted. It keeps its dollars. The `QUOTA_REFRESH`
  server instruction and the `enrich_titles`, `bulk_enrich_status` and
  `build_campaign` descriptions no longer show refreshed quota after every
  enrichment run. The job progress message drops its cents-spent segment.
- **A name lookup no longer answers with a different company** (#229,
  product#4130). `leadbay_research_lead_by_name_fuzzy` took the typeahead's
  first hit, which also matches on trigram similarity above 0.3, so "THEOMA
  GESTION PRIVEE" returned PILOTE GESTION. A company hit now counts only when
  every word of one name is in the other, with one wrong, missing or extra
  letter per word. Otherwise the lookup goes on to the registry.
- **`leadbay_research_lead_by_id` refuses a missing `leadId`** (#228, Sentry
  MCP-46). The call reached `/lenses/48189/leads/undefined` plus five
  sub-requests. It is now `INVALID_PARAMS` before any request. `new_lens`,
  `set_qualification_questions` and `find_new_leads` declare the backend's
  limits: 255 characters, `min_ai_score` in [-30, 30], at most 10
  `contact_titles`.
- **Artifact skin readable in dark mode** (#227, product#4057). The default
  chip label read 1.16:1 in dark mode. The semantic colour pairs now flip with
  the theme, long emails wrap inside the card, success and error pills carry
  different glyphs, and a saved button stops showing "saved" after 1.6 s.

## 0.38.1 — 2026-09-12

Five fixes merged since 0.38.0 (#216, #219, #220, #221, #225). The backend
changes #219 and #220 describe (backend#2030, #2031) reached production in
backend v3.24.0.

- **`leadbay_lead_job_status` results fit in one host result** (#221). A
  delivered item carried the backend's whole `QualifiedLead` (~6.6 KB). 23 of
  them made a 158 KB result that Claude Code moved to a file the session could
  not open (SnapLock rehearsal, prod job `24203dfe`). `splitItems` now keeps
  what the tables read, and trims later rows to company, scores and contact.
  The real job went from 152,750 to 32,114 chars.
- **A pacing 429 is not a credit wall** (#221). The 10-per-hour and 3-running
  lead-job refusals (`rate_limited` / `active_job_cap`) said "Quota exceeded …
  top up", so the agent offered a Stripe link on an unlimited org. On the
  submit endpoints they now say it is a pacing limit, to wait, and to read the
  running jobs. The code stays `QUOTA_EXCEEDED`.
- **`leadbay_bulk_enrich_status` counts settled reservations** (#216,
  product#4102). It read `email` / `phone_number` off the `source:"paid"`
  record, which never carries them, so every email or phone poll read
  `done: 0`. It now reads `enrichment.done`.
- **`max_cost` defaults to 100000 on every plan** (#219). `find_new_leads` and
  `qualify_leads` described a plan-tier default (500/2000/5000). The backend
  now keeps what the requested emails and phones cost out of qualification's
  reach, and refuses a cap below it.
- **Usage is a share of the plan's quota** (#220, #225). Descriptions, prompts
  and runtime hints no longer quote per-lead prices or call reveals "paid" or
  "billed". Consent asks name what will run, with no amount. The
  `leadbay_account_status` quota gauge keeps its dollars, because there the
  user knows it is their plan.

## 0.38.0 — 2026-09-10

`leadbay_account_status` now returns `mcp_version`, the version of the server
answering the call, on every call and on both the stdio and hosted servers.

Before this, an agent asked "what version of Leadbay are you running" had no
answer. The version reached the host only in `initialize`'s `serverInfo`, which
hosts keep out of the model's context, and in
`update_available.current_version`, which exists only while an update is
pending and never on hosted (`http-server.ts` wires no update store).

The server wrapper sets the field next to `update_available`; the composite's
`outputSchema` declares it, and the description gains a routing trigger and one
sentence telling the agent to answer version questions with it.

## 0.37.0 — 2026-09-08

Expose the backend MCP-first delivery endpoints (`POST /1.6/mcp/search`,
`POST /1.6/mcp/qualify`, `GET /1.6/mcp/jobs/{id}`) as three composites —
`leadbay_find_new_leads`, `leadbay_qualify_leads`, `leadbay_lead_job_status` —
plus the `leadbay_new_leads` guided prompt. One ask → net-new qualified
contactable leads, replacing the pull→select→web_fetch→poll→enrich chain.

Free by default (`qualify: false`, no `channels`). Any paid call is withheld
until `confirm: true` and returns a real backend quote instead; `confirm: false`
vetoes with no round-trip. A derived `request_id` makes a retried paid batch
return the same job rather than double-charge, and `max_cost` stops the job with
`stop_reason: "max_cost"`.

The `LEADBAY_MCP_LEAD_DELIVERY` rollout flag introduced with these tools is
retired: the routes shipped to production in backend v3.22.0 (2026-08-22) and
were verified live on `api-us` and `api-fr`. The `leadbay_new_leads` prompt is
still hidden on a read-only server (`LEADBAY_MCP_WRITE=0`), since every phase of
it calls a write-tier tool.

## 0.36.0 — 2026-09-08

The OpenAI submission form flags every tool without an `outputSchema`
("Recommended: Add an outputSchema so models can better understand this tool's
results"). 43 of the 58 tools on `/chatgpt/mcp` already had one. This adds four
of the remaining 15:

- `leadbay_account_history`, `leadbay_team_activity` — rich nested payloads the
  model previously had to infer from prose.
- `leadbay_getting_started`, `leadbay_get_artifact_runtime` — static manifests; cheap to
  declare, and both are consumed programmatically.

Each gets a real entry in `output-schema-conformance.test.ts` CASES, not an
OPT_OUT: a mocked happy-path round-trip whose structuredContent is asserted
against the schema. Mutation-checked — renaming `trend` in the team_activity
schema fails with `$.trend: return contains key not declared in
outputSchema.properties (drift)`.

A local Codex review of this branch found three real defects in the first pass,
all fixed here:

- `account_history` declared `signals` as an object. `research_lead_by_id`,
  whose payload it passes straight through, declares it an **array**. The
  conformance walk checks required keys and undeclared keys, not types, so it
  did not catch this.
- The `account_history` conformance case under-mocked the fan-out. It never
  mocked `/leads/{id}/contacts?IncludeEnriched=true`, and gave one generic
  `/activities` script for the two reads that happen (research's `count=20`,
  then the tool's own `count=<n>`). The harness consumes a script once, so the
  second read rejected, `.catch()`ed to empty, and the case asserted a degraded
  timeline while staying schema-conformant. Proven: with the old mocks the new
  `account-history-full-fanout.test.ts` fails `expected [] to have a length of
  1`.
- `artifact_kit`'s schema omitted `_meta`. `buildServer` injects
  `_meta.update_available` / `_meta.notifications` into successful object
  results before setting `structuredContent`, so it is a real top-level key at
  runtime. The other three declared it.

Two deliberate limits:

- **`leadbay_list_sectors` is excluded.** It returns a bare array, and
  `server.ts` only emits `structuredContent` for plain objects, so a schema
  there would promise a payload the server never sends. Making it honest means
  changing the return to an object, which breaks existing callers.
- On `account_history`, the blobs passed through verbatim from
  `research_lead_by_id` (`signals`, `firmographics`, `contacts`, `engagement`)
  are declared as bare objects with no `properties`. The conformance walk only
  recurses into declared object-properties that carry their own `properties`,
  so research can grow a field without breaking this tool.

The remaining 10 tools without a schema are write acknowledgements
(`pin_contact`, `like_lead`, `set_telemetry`, …). A schema for `{ok:true}`
teaches the model nothing; left alone on purpose.

## 0.35.2 — 2026-09-08

A backend `bad_request` 400 is now `BAD_INPUT` (product#4085).

**What happened.** On 8 Sep an unattended routine on the hosted server called
`leadbay_research_lead_by_id` twenty times in 39 seconds with the first block
of each lead UUID (`/lenses/48110/leads/5585c198`, …). The backend rejected
every call in ~30 ms with `400 {"error":{"code":"bad_request","message":"bad
'leadId' parameter"}}`. `client.ts` `mapErrorResponse` filed that under the
`API_ERROR` catch-all, whose hint reads "Try again or check the Leadbay API
status". The agent did what it was told. The same pattern ran on 31 Aug.

**Change.** A 400 whose body carries the backend's `bad_request` code (a path
or query parameter that failed to parse, a body that failed to deserialize) is
`BAD_INPUT`, with the backend message verbatim and a hint that the call fails
identically on retry and that ids are the full values Leadbay returned. Other
400 codes (`duplicate`, `unpaid_invoice`, `not_allowed`, …) are domain answers
and stay `API_ERROR`. `leadbay_research_lead_by_id`'s `leadId` schema
description now says the id is the full 36-character UUID from
`leadbay_pull_leads` `items[].id`, never shortened. No template change.

## 0.35.1 — 2026-09-08

Release-pipeline only. No source change, no behaviour change: `packages/core`
and `packages/mcp` are byte-identical to 0.35.0. This version exists because
the MCP Registry can only be reached by a tag push, and the two `release.yml`
bugs fixed in #208 had to ship before a tag could carry it.

**What was broken.** 0.35.0 published to npm and then the workflow died
before creating the GitHub Release: the release-notes pipeline ended in
`head -60` under `set -euo pipefail`, the 0.35.0 changelog section is 115
lines, so `head` closed the pipe, GNU sed failed its final flush and the step
exited 4. No Release meant no `.dxt`/`.mcpb`, and `Publish to MCP Registry`
was skipped for `needs:`-ing the job that failed. Separately, on 0.34.1 the
registry job did run and the registry refused it — it re-checks npm itself and
got a 404 for a version published seconds earlier. Between the two, the
directory listing sat at 0.34.0 and the desktop bundle at 0.34.x while npm and
the hosted server were on 0.35.0.

0.35.0's Release and its four assets were created by hand once #208 landed;
this release is what carries the fix through the pipeline itself.

## 0.35.0 — 2026-09-02

The MCP no longer keeps any record of the jobs it starts (product#4005,
product#4039). Every launch already receives a durable id from the Leadbay
backend, and the status tools now take that id.

**Why.** Three tools — `leadbay_bulk_qualify_leads`, `leadbay_import_leads`,
`leadbay_import_and_qualify` — failed on the hosted server with
`BULK_TRACKER_UNAVAILABLE` (9 customers, 53 times; `import_and_qualify` never
once worked there). The file they depended on (`~/.leadbay/bulks.json`) was only
ever created by the stdio entrypoint. Its own header said it existed *"while the
Leadbay backend doesn't yet issue a real bulk_id"*; the backend now does.

**Breaking.** `bulk_id`, `handle_id` and `qualify_id` are gone as inputs, with
no transition period — they were valid for minutes on a local install and never
worked on the server.

| Tool | Now returns / takes |
|---|---|
| `leadbay_enrich_titles`, `leadbay_bulk_qualify_leads` | return the backend's `notification_id` + `lead_ids` (+ `lens_id`) |
| `leadbay_import_leads`, `leadbay_import_and_qualify` | return the backend's `importIds` / `import_ids` |
| `leadbay_bulk_enrich_status`, `leadbay_qualify_status` | take `notification_id` and/or `lead_ids` (+ `lens_id`) |
| `leadbay_import_status` | takes `importIds`, which it already accepted |

`packages/core/src/jobs/bulk-store.ts` and its tests are deleted, along with
`LEADBAY_BULK_STORE_PATH`, `LEADBAY_BULK_STORE_ALLOW_MEMORY` and the `BULK_*`
error codes. What survives is a five-minute in-memory guard so an assistant
retry does not charge twice (`jobs/launch-guard.ts`); it claims the launch
BEFORE firing it, so a concurrent identical call is told `launch_in_flight`
rather than handed an empty ticket (product#4039).

Verified live on staging through fresh server processes: a job started in one
process resolves by its backend id from another, including after the
notification was archived with `leadbay_acknowledge_notification`. The
notification is listable within a second of the launch ack.

Found in that run and fixed in the same release:

- **A notification of the wrong kind was answered as if it were the right
  one.** A qualification's notification fed to `leadbay_bulk_enrich_status`
  read as a finished enrichment; an enrichment's fed to `leadbay_qualify_status`
  read as a running qualification. Both tools now check the notification's kind
  (`ENRICH_JOB_WRONG_KIND` / `QUALIFY_JOB_WRONG_KIND`) and name the right tool.
- **Enrichment notifications do not always carry counters.** On staging the
  finished row had `in_progress` and a title but no `bulk_progress`, so the
  `notification_id`-only call said "not a bulk job". It now says
  `ENRICH_JOB_NO_COUNTERS` with the backend's running/finished flag and asks for
  the `lead_ids` the launch returned, which always answer.
- `leadbay_qualify_status` no longer throws not-found when the caller also
  passed `lead_ids` + `lens_id`; it answers per lead, as its own hint says to.
- `leadbay_enrich_titles` declared `notification_id` / `lead_ids` / `reused`
  as inputs instead of outputs; `leadbay_bulk_enrich_status` required
  `notification_id` in its output when the `lead_ids`-only path omits it.
- The artifact SDK's `lb.enrichment()` (`@leadbay/components`) still read
  `bulk_id`, so every widget enrichment reported done instantly with no job
  polled. It now carries `notification_id` + `lead_ids`.
- The live eval harness and two live smoke suites still imported the deleted
  store; tool copy in eight places still told the assistant to poll with a
  `bulk_id` / `qualify_id`.

**The assistant is now told, in the tool descriptions, that a launched job
cannot be stopped.** Leadbay has no cancel: enrichment, qualification and
imports have a launch route and a read route and nothing else
(`LeadsRoutes.kt`, `ImportsRoutes.kt`, `/1.6` specs). The retired store made
this reachable in the wrong direction — a host cancellation flipped a local
record to `cancelled`, and the status tools answered *"no further work is in
flight … relaunch"*, which spends the quota a second time on rows Leadbay is
still processing. That text is gone with the store; the true rule now ships on
the surface the assistant actually reads. Three variants ship, because handing a tool
the wrong one is its own bug.

| Tools | Snippet | Why |
|---|---|---|
| `enrich_titles`, `bulk_qualify_leads`, `import_and_qualify` | `heuristics/launched-work-cannot-be-stopped.md` | they call `beginLaunch`, so a re-call inside the window returns the ids the first call produced — best-effort, never a guarantee |
| `bulk_enrich_status`, `qualify_status`, `import_status` | `heuristics/launched-work-poll-only.md` | read-only. Re-calling launches nothing, and the launcher's "check your quota before retrying" text would stall the poll loop these tools exist for |
| `qualify_lead`, `enrich_contacts`, `launch_bulk_enrichment` | `heuristics/unguarded-launch.md` | they POST directly with no guard, so the guarded re-call advice would buy a second paid launch. `enrich_contacts` is on the hosted route, so this is not only an advanced-mode concern |

Those same three tools shipped `idempotentHint: true`, which this repo defines as
*"calling the same tool twice with the same arguments is safe and produces the
same observable outcome (no double-write side-effect)"*. That is the
machine-readable half of the claim the prose now contradicts, and a host may act
on it automatically. They are `idempotentHint: false` now. This is the one
behaviour change in an otherwise text-only release;
`packages/mcp/test/annotations.test.ts` carried the old value in its expectation
table and is corrected with the code.

The six prompts that already carry the long-running-tool rules carry the guarded
variant too. `leadbay_import_leads` sits 157 chars under the
17,000 cap and states the cancel case in one sentence instead; its own SLOW
BACKEND paragraph already carries the rest.

The rule applies to a launched or running result only, never to a discovery,
preview or `dry_run` result, and it branches because recovery does. With a
handle: poll it, and do not launch the work that handle covers again. With a
handle and a subset the result says never started — `failed[]` entries with
`error:"not_queued"`, or a `rows_pending_upload` count — re-run that subset
only. With no result at all: read `leadbay_account_status` first, because the
double-launch guard is in-memory, five minutes and per process, and
`bulk-qualify-leads.ts` drops its claim when the launch POST throws even if the
backend accepted it. The rule says so rather than promising a recovery the guard
cannot deliver.

`cancelled: true` on the two import results is described the same way instead of
as *"ctx.signal aborted mid-flight"*, each names the flag a timeout uses instead,
and the import one says the counts can stop moving on a chunk cancelled before
its mappings were committed (product#4064).
`packages/mcp/test/audit/launched-work-not-cancellable.test.ts` holds both halves
— every branch is present on the guarded tools, the no-guard warning on the
unguarded ones with the guarded advice asserted absent, matched on collapsed
whitespace so a rewrap cannot silently pass it, and no generated description or
prompt can tell the assistant to relaunch after a cancel again.
`packages/mcp/test/launched-work-rule-over-the-wire-e2e.test.ts` proves the same
thing where it counts, driving the real Hono app on a real socket through
`StreamableHTTPServerTransport` and reading `tools/list` — the catalogue objects
the audit reads are not what a chat host receives.

## 0.34.1 — 2026-09-07

`leadbay_set_lead_status` threw `TypeError: (params.lead_ids ?? []).filter is
not a function` five times in 29 s on the hosted route (product#4079, Sentry
MCP-3T): a scheduled agent passed `lead_ids` as something other than an array.
The same class hit `leadbay_new_lens` on 2026-08-04 (`texts.filter is not a
function`, `locations` as a string, MCP-3B). Both times the agent retried the
identical call four or five times, because a raw TypeError names no argument.

The CallTool handler in `server.ts` never checked `inputSchema` before
`tool.execute`; its own comment said enforcement was ours, and it only enforced
`_triggered_by`. 32 tools declare a top-level `array` or `object` parameter and
none of them defended against a string: four threw, two silently corrupted
(`adjust_audience` spread `"tech"` into `["t","e","c","h"]`), the rest forwarded
the string to the backend.

One guard now runs before `execute`, after the `LAST_PROMPT_REQUIRED` check
(`findShapeMismatch`): for each top-level schema property whose `type` is
`array` or `object`, a present non-null argument of the wrong JSON shape returns
`BAD_INPUT` naming the field, the expected shape and the received type
(`lead_ids must be a JSON array (got string)`), through the same envelope branch
a tool's own `BAD_INPUT` takes. So PostHog sees `ok:false error_code:BAD_INPUT`
and Sentry groups the event with the tool's existing `BAD_INPUT` issue
(`source:business`) instead of a `source:unexpected` stack fingerprint.

Deliberately not done: no coercion of a bare string into a one-element array (a
JSON-stringified array would become one bogus id and fail per lead inside
`failed[]`); no scalar checks (hosts send `"20"` for a number and tools coerce);
no `required`, `items`, nested or `additionalProperties` validation; no runtime
dependency. `null` counts as absent. One visible change on an existing tool:
`leadbay_report_outreach` with `verification` given as a string answered
`VERIFICATION_REQUIRED`; it now answers `BAD_INPUT … must be a JSON object (got
string)`. Tools executed directly outside the MCP server (OpenClaw consumes
`@leadbay/core` without `buildServer`) keep the raw throw.

Test: `packages/mcp/test/unit/input-shape-guard.test.ts` drives `tools/call`
through `buildServer` with the incident shapes.

## 0.34.0 — 2026-09-02

The OpenAI app directory rejects an app that sells digital goods — "plugins may
conduct commerce only for physical goods. Selling digital products or services
— including subscriptions, digital content, tokens, or credits — is not
allowed" — and separately forbids promoting the purchase: a plugin "must not
display subscription plans, initiate new subscriptions, or promote upgrades" or
"link directly to a checkout". Signing in to an existing paid account and using
what it entitles you to IS allowed. Anthropic's Software Directory Policy has
no equivalent clause; its nearest rule bars software that "executes financial
transactions on behalf of users", which our Stripe-URL handoff does not do.

`POST /chatgpt/mcp` is the URL submitted to OpenAI. Same Hono app, same auth,
same handler, built with `includeCommerce: false`. Four things go away
together — the tools, and every text that would promote buying them, since
removing only the tools left the agent still pushing a top-up it could no
longer produce:

- `leadbay_create_topup_link` and `leadbay_open_billing_portal` are not
  registered (filtered after the catalogue arrays merge — both appear in
  `compositeReadTools` and `granularReadTools`).
- Tool descriptions lose their `{{commerce}}` blocks
  (`NO_COMMERCE_TOOL_DESCRIPTIONS`, emitted by promptforge).
- The `QUOTA_TOPUP` instruction paragraph is not pushed. `quota-topup.md` was
  split verbatim: the selling paragraph is gated, and the neutral one that says
  when to re-render the quota gauge became `quota-refresh.md`, pushed on both.
- `LeadbayClient.commerce` drops the two selling sentences from the
  QUOTA_EXCEEDED hint ("OR top up AI credits…", "…or direct them to
  app.leadbay.ai → Billing"). One client per session on hosted, so the flag
  cannot leak across tenants.

Nothing is reworded for ChatGPT. `{{commerce}}` only ever DELETES — there is no
second, softened wording anywhere to drift out of sync, and the Claude surface
keeps selling exactly as hard as before. Enforced two ways:
`commerce-gate.test.ts` asserts the gated description and instruction strings
are pure character-level *subsequences* of the Claude ones (any reworded
character fails), and the generated descriptions were diffed against `main` to
confirm the default rendering did not move by a byte.

A path rather than an `initialize` clientInfo sniff, and rather than a query
parameter: the OAuth protected-resource identifier IS the path
(`/.well-known/oauth-protected-resource/chatgpt/mcp` → `resource:
https://mcp.leadbay.app/chatgpt/mcp`). A `?commerce=off` flag would leave both
URLs advertising the same resource, so a client reconnecting to the audience it
registered would get the selling tools back. RFC 8707 also says a resource URI
should carry no query component.

`RESOURCE_PATHS` gains the path so OAuth discovery resolves for it, and the
installer's ChatGPT Desktop entry now hands out `HOSTED_MCP_URL_CHATGPT`.

## 0.33.4 — 2026-09-02

`leadbay_enrich_contacts` moves from `granularWriteTools` to
`compositeWriteTools` (product#4050).

It is the only tool that enriches ONE chosen person — `leadId` + `contactId`,
paid-candidate path first, org-contact path on `NOT_FOUND` — but it sat behind
`LEADBAY_MCP_ADVANCED=1`, which hosted never sets. No granular tool has been
called from a hosted IP in 30 days. The daily check-in prompt and the pull-leads
NEXT STEPS table already told the agent to call it, so on hosted they named a
tool that was not registered. `zoe+dogfood@leadbay.ai`'s scheduled agent
reached for `leadbay_pin_contact` instead (41 `contact not found` over 12 days;
see 0.33.3). Same registration pattern as add/remove/pin/unpin/update_contact
and set_lead_status: granular-shaped, lives in `tools/`, default write surface,
still hidden by `LEADBAY_MCP_WRITE=0`. Not in `COMPOSITE_FILE_TOOL_NAMES`, so
`_triggered_by` stays optional.

What moved with it, so the tool works on the surface it now lands on:

- **The result hint names a read that exists there.** It said "re-check
  `leadbay_get_contacts`", which is advanced-only. It now says re-read the
  lead's contacts via `leadbay_research_lead_by_id` (or `get_contacts` where
  exposed).
- **The description carries routing.** `routing` + `rendering_hint`
  frontmatter, added to `TOOLS_WITH_ROUTING`; the `prefer_when` states that a
  `source:"paid"` candidate id from `research_lead_by_id` is valid input and
  that pinning does not enrich anyone. Cross-routes to `enrich_titles`
  (by title, many leads), `pin_contact`, `prepare_outreach`.
- **The pin/unpin 404 hint and `heuristics/pinnable-contacts` name it** as the
  direct route alongside `enrich_titles` and `add_contact`. `enrich_titles`'s
  WHEN NOT TO USE drops the "(granular)" label.

Tests: `packages/mcp/test/enrich-contacts-default-surface.test.ts` drives the
real MCP `tools/list` — present with `includeWrite:true, includeAdvanced:false`,
absent with `includeWrite:false`, and every `leadbay_*` the description and
the call hint name is itself registered on that surface.
`packages/core/test/unit/tools/pin-contact-hint-names-enrich-contacts.test.ts`
pins the hint.

## 0.33.3 — 2026-09-02

`leadbay_pin_contact` failed 43 of its 48 production calls.

Measured over the 180 days to 2026-09-02 (PostHog `mcp tool called`,
`properties.ok = false`). Sentry issue `MCP-3A` carries exactly 43 events, so
every `contact not found` in the `mcp` project is a pin call. 41 of the 43 are
one scheduled agent, `zoe+dogfood@leadbay.ai`, across 12 days and MCP 0.26.0
through 0.33.2. Its own `triggered_by` reads *"tâche planifiée : épingler le DG
Mickael Hamot (CROMOLOGY SERVICES) à la place du président"*. It picks the DG
out of `leadbay_research_lead_by_id` and pins that id, which on an unenriched
company is a `source: "paid"` candidate. It has never once succeeded, and pin
is its third most used tool.

`POST /contacts/{id}/pin` resolves through `org_contacts` only
(`OrgContactRoutes.kt:44`), so a paid candidate id can never resolve there. Two
changes:

- **The 404 now names its cause.** The client's shared 404 hint is "Verify the
  ID is correct" (`client.ts:1048`), which on this endpoint is false: the id is
  correct, it is the wrong namespace. An agent reads that as "look it up and
  retry" and does, in bursts of up to 10 calls in 13 seconds. pin and unpin now
  catch `NOT_FOUND` and replace the hint with one that names the org-vs-paid
  split, says not to retry, and names the tools that make the person pinnable.
- **The descriptions carry the rule.** New shared snippet
  `snippets/heuristics/pinnable-contacts.md`, included by both templates, plus
  the rule in `prefer_when` so it lands in the first 600 chars every host reads.
  It also states that pinning does not steer enrichment: enrichment selects by
  job title (`resolveAutoIncludedTitles` →
  `paidContacts.findSimilarJobTitlesWithScores`) and `pinnedBy` plays no part.
  That was the second half of the agent's mistake.

**`pinned` is now readable.** The backend's `ContactPayload` has carried
`pinned` + `pinned_by_ai` all along; every MCP shaping site dropped them, so a
pin could be written but not read back except by watching `recommended`, which
moves for other reasons too. Now passed through in `research_lead_by_id`,
`get_contacts` and `get_lead_profile`, and marked `📌` in both markdown contact
lists. Deliberately asymmetric: `PaidContactPayload` has no pin state, so paid
contacts get no `pinned` key rather than a synthetic `false`.

Verified live against FR staging, running the built branch:

```
BEFORE PIN:  org contact → pinned: false     paid contacts → no pinned key
AFTER  PIN:  org contact → pinned: true      (pin http 204)
AFTER UNPIN: org contact → pinned: false     (unpin http 204)
```

Remaining, not fixed here: on hosted an agent still has no way to enrich one
named person. `leadbay_enrich_contacts` takes a `contact_id` and does exactly
that, but sits in `granularWriteTools` behind `LEADBAY_MCP_ADVANCED=1`, and no
granular tool has been called from a hosted IP in 30 days. That is why the
agent reached for pin. Filed as leadbay/product#4050.

## 0.33.2 — 2026-09-02

Editing one field on a contact erased the others (product#4046).

Reproduced on production while verifying leadbay/mcp#194: sending a contact's
own current `first_name` + `last_name` + `job_title`, changing nothing, deleted
that contact's email. The contact moved from `contacts.reachable` to
`contacts.candidates` and `_meta.has_reachable_contact` flipped to false, so the
lead stopped being contactable. Restored with a second call.

This became reachable only the day before: until #194 the tool returned 404 on
100% of calls, so it destroyed nothing. A tool that always failed now succeeded
and deleted data.

**The backend already had the safe behaviour and we were choosing the other
one.** Both routes take the same payload
(`OrgContactRoutes.kt:110,152` → `OrgContactsDaoImpl.kt:265-272`):

| Route | `forceUpdateIfNullOrEmpty` | A field absent from the body |
|---|---|---|
| `/contacts/{id}/update` | `true` | written as null — **erased** |
| `/contacts/{id}/merge` | `false` | skipped — **kept** |

So the fix needs no read-modify-write, no extra round-trip and no race window.
`leadbay_update_contact` now routes by intent:

- **Nothing being erased → `/merge`.** Every field the caller omitted survives.
- **Any field passed as `null` → `/update`**, which rewrites the record — so
  that call must carry all four optional fields. If it does not, the tool
  refuses with `CONTACT_CLEAR_NEEDS_FULL_RECORD` rather than deleting what was
  not mentioned. The destructive path costs more effort than the safe one,
  which is the intended asymmetry.
- The result carries `mode` (`merge` / `replace`), `preserved` and `cleared`, so
  a wrong edit is visible rather than silent.

Verified live on staging, same body to each route:

```
POST /contacts/{id}/merge   {first_name, last_name, job_title:"CEO"}
  → job_title CEO,  email merge.probe@example.test,  phone +15550001111   (kept)
POST /contacts/{id}/update  {first_name, last_name, job_title:"CTO"}
  → job_title CTO,  email absent,  phone absent                           (erased)
```

Two existing test files were changed rather than added, because both asserted
the contract this fixes: `update-contact.test.ts`'s happy path now expects
`/merge`, and `update-contact-null-clear.test.ts`'s clear case now supplies the
whole record. The second had encoded the data loss as correct — it asserted that
unmentioned fields are not sent, while posting to the route that deletes them.

## 0.33.1 — 2026-09-02

Follow-up to 0.32.0 (product#4007). A review finding landed after the merge.

- **Only a REFUSAL counts as a commit failure.** The detached finisher's catch
  recorded ANY rejection from the `pollPreprocess` → `commitMappings` chain as a
  permanent commit failure — including the `ImportPhaseTimeout` raised when
  preprocess still hasn't finished after the finisher's own 10-minute window.
  That is not a backend refusal; the import is merely slow and may yet finish.
  The registry has no expiry, so `leadbay_import_status` would have answered
  `failed` for ever on a healthy import — worse than the forever-polling 0.32.0
  set out to remove. A budget timeout now leaves the status at `committing`,
  which is the honest reading; only an error from `commitMappings` itself is
  recorded.
- The finisher's window is deliberately not caller-controllable — a caller
  shrinking it is the bug that budget exists to prevent — so the test reaches it
  through a named module seam beside the existing `clearCommitFailures`, rather
  than a parameter that would leak into the tool's public schema.

## 0.33.0 — 2026-09-01

Agent memory is removed (product#3996). The three `leadbay_agent_memory_*`
tools, `packages/core/src/agent-memory/`, the `agent-memory://summary`
resource, the `_meta.agent_memory` decoration and the `AGENT_MEMORY` server
instruction are all deleted.

**Why it goes rather than gets fixed.** The store was written when the MCP ran
on the user's own machine and no host had memory of its own. Both premises are
dead. Claude Cowork projects carry persistent memory into their scheduled
tasks, Claude chat memory shipped to all plans, ChatGPT has memory plus remote
connectors, and Codex reads the user's own file. Meanwhile the hosted server
writes to a container layer that Argo replaces on every image build, so ours
was strictly worse than the host's on the surface where it mattered most.

**Measured before deciding.** PostHog `mcp tool called`, tools matching
`%memory%`, 120 days, whole fleet: **121 captures, 30 recalls, 13 users.**
79 of the 121 captures came from one hosted IP and stop on 2026-07-29. August
was 11 captures and 3 recalls. The feature wrote four times more than it was
read and almost nothing read it twice.

**What replaces it.** Reading that user's verbatim `triggered_by` sentences
rather than a summary of them, the largest group by far was targeting — *"je
vise en priorité les entreprises qui ont plus de 100 voitures dans leur parc"*,
*"les transporteurs ne sont pas forcément de bons prospects sauf ceux qui font
du last mile delivery"*. Stored as notes those changed one conversation. The
new `headers/durable-preferences` snippet, included by the seven prompts that
carried the memory preamble, routes them to `leadbay_refine_lead_targeting` instead,
where they mutate `computing_intelligence` and change what the product finds
for the whole organization. A rejected lead goes to a dislike.

- `withAgentMemoryMeta` is unwrapped from nine composites. It called
  `resolveMe()` on every invocation, so each of those tools loses a round-trip.
- `LEADBAY_AGENT_MEMORY`, `EV_AGENT_MEMORY_*` and the three
  `captureAgentMemory*` telemetry methods are gone. Historical events stay in
  PostHog; nothing new is emitted.
- `assembler.ts` no longer injects a memory pointer into routed tool
  descriptions, and `memory_protocol` is removed from the frontmatter schema.
- Deliberately NOT landed: `agent-memory/durability.ts`, a boot-time warning
  for an ephemeral memory root written while this was still going to be fixed
  with a volume. With nothing writing to disk the condition it detects can no
  longer occur, so it is not a safety net, it is dead code.

## 0.32.6 — 2026-09-01

The 401 auto-retry is GET-only — replaying a write could double-execute a
mutation that already committed — but `mapErrorResponse` wrote its hint as if
`request()` had always retried. On `leadbay_create_topup_link` (a POST) the
agent was told the call had already been retried when it had been attempted
exactly once. Covers acceptance criteria 4 and 5 of product#3998; the 401 itself
is a backend bug fixed in leadbay/backend#1989.

- The GET-only rule is extracted into `retriesOn401` so the retry path and the
  error mapper read one source of truth, and the actual outcome is threaded into
  `mapErrorResponse` rather than assumed.
- The not-retried hint states only the fact and never the reason. A GET with
  `retryOn401:false` (the startup auth probe) is not a write, and the earlier
  text told it that it was. Why the retry didn't run is not actionable for the
  agent — only that this was attempt one — so the flag stays a boolean.
- `readOnlyHint: false` on both Stripe tools. Each mints a Stripe session, and
  for an org with no customer yet the shared `getStripeCustomer` path creates
  the customer and persists `organizations.stripe_customer_id`.
- The two Stripe tools stay in `granularReadTools`. `readOnlyHint` (a client
  confirmation hint) and `includeWrite` (a capability gate over the user's
  Leadbay data) are different axes, and this repo already treats them that way
  — `leadbay_preview_bulk_enrichment` sits in `granularWriteTools` with
  `readOnlyHint: true`. Moving them would leave a `--no-write` user who hits
  quota with no top-up link at all, which is the exact failure product#3998 was
  filed for.

## 0.32.5 — 2026-09-01

`leadbay_update_contact` returned `NOT_FOUND` / 404 on 100% of the calls ever
made to it (product#3997). Not a missing route and not a backend bug.

Leadbay holds contacts in two id namespaces — `org_contacts` (the org's own
directory) and `paid_contacts` (enrichment results) — with separate models,
separate DAOs and separate ids. `POST /contacts/{id}/update` resolves
`orgContacts.findById` only, so a paid id can only ever 404.

`research_lead_by_id` merges `/leads/{id}/enrich/contacts` and
`/leads/{id}/contacts` into `contacts.reachable` / `contacts.candidates`, split
by **whether the person is messagable right now, not by which endpoint they came
from**. That split is right for outreach and wrong for identity: an enriched
paid contact sits in the same list as an org contact, and the agent had nothing
to distinguish them by. Roughly half the ids we handed out were unusable by a
tool the server instructions tell the agent to call after every outreach.

- The `source` field (`"org"` / `"paid"`) was already on the wire but undeclared
  and unexplained. It is now documented in `research_lead_by_id`'s output schema
  as the thing that decides whether an id can be passed to
  `leadbay_update_contact`.
- `leadbay_update_contact`'s description states which namespace it accepts,
  what a 404 means, and routes a correction to a paid contact through
  `leadbay_add_contact` instead — the enrichment row is a provider's answer and
  is not ours to edit.
- New: `research-contact-source-provenance.test.ts`, covering the case the
  reachability split hides — an enriched paid contact and an org contact in the
  SAME `reachable` list, distinguished only by `source`.
## 0.32.4 — 2026-09-01

`leadbay_account_status.notifications` was permanently `[]` on the hosted server
(product#4009). Same shape as product#4005: `buildServerFromClient` builds every
hosted server without a `notificationsInbox`, `BuildServerOptions` makes it
optional, so it compiled and failed silently.

**Not fixed by wiring the inbox, because the inbox cannot exist there.** It is
fed by a WS listener whose ticket (`GET /auth/ws`) is per-account, which is
meaningless on a multi-tenant process, and the streamable transport builds a
fresh `Server` per request so nothing would survive to be cached. Porting it
would be the same mistake in a third place.

`GET /notifications` is already per-account and already durable. On hosted the
ledger IS the inbox, so `account_status` reads it on demand when no inbox is
wired. Stdio is unchanged and still free — the WS listener already has the
answer, and the ledger is not called.

- `fetchTerminalNotifications(client)` in `notifications/catch-up.ts` returns
  terminal, unseen entries directly. `isTerminalUnseen` is extracted so inbox
  seeding and the inbox-less read cannot disagree about what counts.
- A notifications failure resolves to `[]` rather than throwing. `account_status`
  is the daily entry point; a ledger hiccup must not take the check-in down.
- **Deliberately not done: `_meta.notifications` still does not ride hosted tool
  responses.** Reviving it would mean a `GET /notifications` per tool call to
  decorate every response. The cost lands on the check-in entry point only,
  which is where the daily-rhythm channel is actually read.
## 0.32.0 — 2026-09-01

A poll-budget timeout stops being an error (product#4007). The import wizard's
phases are bimodal — ~7s or ~85s, essentially nothing in between — and
`DEFAULT_PER_PHASE_BUDGET_MS` (60s) sat squarely in the gap, so the same input
randomly succeeded or threw. One reported session: 21 successful imports and 9
timeouts on a single spreadsheet, all nine timeouts issued from one user
instruction. The agent re-issued the identical call nine times over eleven
minutes, because an error is a thing you retry. Account details are in
product#4007; this file is published to npm, so they stay there.

- **`IMPORT_BUDGET_EXHAUSTED` → `IMPORT_TIMEOUT`.** No alias. "Budget" meant
  milliseconds and every reader — customer, support, engineer — parsed it as
  money. There is no import billing cap. Keeping the old string alive in
  telemetry would preserve the exact confusion the rename exists to end.
- **The blocking path returns `{status:"running", timed_out:true, importIds}`
  instead of throwing.** `ImportPhaseTimeout` is now an internal signal raised by
  the three poll sites (preprocess / process / records-terminal) and converted to
  a result in `execute`. A real backend error — `IMPORT_PREPROCESS_FAILED`,
  `IMPORT_PROCESSING_FAILED` — still throws; degradation is timeout-only.
- **A preprocess timeout parks the import, so it gets a detached finisher.**
  Found by probing us-staging rather than by reading the code: `update_mappings`
  is the MCP's *own* POST, and it only fires after preprocess. Walk away before
  sending it and the wizard row sits inert forever — `pre_processing.finished`
  true, `processing` absent, `total_records` 0, and `GET /records` answering
  `400 in_progress` indefinitely. `summarizeImports` reads that shape as
  **complete**, so without this the fix would have converted a loud error into a
  silent "running… complete… no leads", which is worse than what it replaced.
  `resumeParkedUpload` hands the uploaded chunk to a detached continuation that
  commits the mappings, so `status:"running"` is a true statement. Process and
  reconcile timeouts need nothing — the backend is already working. Verified
  live on us-staging 2026-09-01: forced timeout → running at t+0, three real
  leadIds out of `leadbay_import_status(importIds)` at t+10s.
- **`IMPORT_NOT_TERMINAL` is retired.** Its hint said *"Retry
  leadbay_import_leads with the same input in 30s"* — our own contract asking
  for the loop. That path degrades like the other two now.
- **`leadbay_import_status` returns leads on the `importIds[]` path.** It used
  to carry `result` only when resolving a `handle_id` against the BulkTracker,
  so an import that timed out mid-poll could be observed as `complete` and still
  leave the agent with no leadIds — whose only route to them was re-running the
  whole import. It now reads `GET /imports/{id}/records` once every named import
  is complete. `MCP_ROW_ID` round-trips through the synthesized CSV, so this
  needs no client-side state — which is what makes it work on the hosted MCP,
  where there is no BulkTracker at all (`http-server.ts` never passes one).
  A records read that fails downgrades to status-only; it never turns a readable
  status into an error.
- **A still-settling record is in neither bucket.** `reconcileOneChunk` can call
  an unresolved record `internal_error` because it only runs after the records
  settled; a status poll has no such guarantee. The status-side reconciler
  counts them as `result.still_settling` instead. Calling a pending row "failed"
  is what sends an agent back into the loop.
- **`notification_id` survives a timeout.** It was collected from
  `runOneChunk`'s return value, so a call that timed out after `update_mappings`
  threw away the very notification that would have told the agent the import
  finished. Threaded out at mint time, mirroring `onImportId`.
- **`import_and_qualify` passes the running shape through** rather than throwing
  `IMPORT_ASYNC_UNEXPECTED`, which would otherwise fire on exactly the incident
  being fixed. Reuses the literal its `wait_for_completion:false` branch already
  returns; `handle_id` is now optional on both.
- **Multi-chunk honesty:** a timeout on chunk 1 of 3 leaves chunks 2-3 unsent.
  Those rows are reported as `rows_pending_upload`, and the description says
  they DO need a fresh call for that subset — the one case where re-importing is
  right.
- Shared record-reading primitives (`normalizeDomain`, `readCell`,
  `PUBLIC_MAILBOX_DOMAINS`, `reconcileRecords`) moved to
  `composite/_import-records.ts`. `import-leads.ts` re-exports `normalizeDomain`
  for backward compatibility.

**Review round 2** — nine findings from the Codex and Claude reviewers, all
real, all fixed:

- **A dry run and an import parked mid-commit are byte-identical on the wire.**
  Probed on us-staging: both answer `total_records: 0`,
  `pre_processing.finished: true`, `processing` absent, `mappings` populated by
  the backend's own AI hints, and `400 in_progress` on BOTH `/leads` and
  `/records`. So the row cannot decide completion — the endpoints can.
  `leadbay_import_status` now asks, and demotes a row that merely *looks*
  finished to `status:"running"`, `phase:"committing"`. Without that, the window
  between preprocess finishing and the detached finisher committing reported
  `complete` with no leads and the agent stopped polling — the exact failure
  this release exists to prevent.
- **`dry_run` is carried by the caller**, because nothing else can carry it.
  The timed-out running result sets it; `leadbay_import_status` takes it as a
  parameter. Absent it, a validation pass reads as `committing` rather than
  risking a "N leads imported" render of an import that committed nothing.
- **`GET /imports/{id}/leads` is the source of truth for lead ids**, unioned
  over the records-derived set, which now only annotates them with
  rowId/domain/name. It is what `import_and_qualify` already trusts and it
  includes leads the import created rather than matched; records-only would
  drop those silently.
- **A snapshot short of the declared `total_records` reports the shortfall as
  `still_settling`.** `processing.finished` can flip before every row is
  exposed; counting only visible rows let a partial snapshot read as final.
- **A record carrying `lead.id` while still `MATCHING`/`IMPORTING` stays
  pending.** The module's own `isRecordTerminal` says settled means `IMPORTED`
  or `NO_MATCH`; the leads bucket now honours it instead of trusting a
  lead id that the wizard may yet re-match.
- **`import_and_qualify` propagates `timed_out`, `rows_pending_upload`,
  `dry_run` and the malformed rows.** Dropping them meant a >100-row batch
  silently lost every unuploaded chunk and the agent had no cue to poll.
- **Malformed rows survive the degraded result.** They are rejected
  client-side and never reach the backend, so `leadbay_import_status` could
  never reconstruct them — dropping them let the caller read the batch as fully
  accounted for.
- **No customer identity in this file.** It ships in the package's npm `files`
  list; incident details belong in product#4007, not on npm.

**Review round 3** — seven more findings on the round-2 code, all real:

- **A transient `/leads` failure was being swallowed as "endpoint missing".**
  Only a 404 is benign; a 500 or auth error means the canonical set is unknown,
  and a records-only `result` would silently omit whatever `/leads` would have
  added. Now only 404 falls back — and fixing it immediately surfaced a missing
  `/leads` mock in the MCP E2E test that the old catch had been hiding.
- **The canonical merge keyed a map by `leadId`,** which collapsed the several
  rows records-mode deliberately allows on one lead (separate contacts on one
  company) and lost each row's `rowId`. Every reconciled row is kept; only ids
  `/leads` knows and no record exposed are appended.
- **A canonical id could resurrect a non-terminal record** through the union,
  undoing the terminal gate added in round 2. Ids belonging to a record that is
  still MATCHING / IMPORTING are held back.
- **The settling deficit counted raw fetched rows,** so a re-paged duplicate
  masked a genuine shortfall. Measured on distinct rows now.
- **The detached finisher inherited the caller's budget.** A caller who passed
  a short `total_budget_ms` is exactly the caller most likely to time out;
  giving the finisher that same window let it fail the one job it exists to do
  and leave the import parked. It now carries its own 10-minute budget, does
  only what it must — poll preprocess, commit the mappings — and is skipped
  entirely for a dry run, which is *supposed* to stop after preprocess.
- **Two tool descriptions still said `leadbay_import_status` returns
  status/progress only**, contradicting the recovery path this release adds.
- **`readCell` never looked at a cell's `field` name.** A records-mode import
  that maps the header `Web` to LEAD_WEBSITE returns
  `{column_name: "Web", field: "LEAD_WEBSITE"}`, and coalescing to the first
  present name let `column_name` shadow `field` — so an unmatched row lost its
  domain and rendered as "needs attention" instead of "pending crawl", without
  the domain needed to retry. Pre-existing, but the stateless recovery path is
  what made it bite.
- **`importIds` is deduped.** The same handle twice doubled the declared row
  count against a record set that dedupes, pinning `still_settling` above zero
  on an import that had entirely finished.
- **Records mode returns `row_ids`.** `MCP_ROW_ID` is a UUID minted inside the
  tool; the caller has never seen it, yet `leadbay_import_status` reports
  recovered leads keyed by it. A row identified only by `CRM_ID` — no website
  to correlate on — was untraceable back to the leadId it produced.
- **A rejected mapping commit no longer polls for ever.** The detached
  finisher sends `update_mappings` on the caller's behalf; if the backend
  refuses it (an invalid mapping answers `400 missing LEAD_NAME field`), the
  row it leaves is byte-identical to one still committing — no error field
  anywhere. Before the timeout became a success result this surfaced as a plain
  error, so staying silent would be a regression introduced by that very
  change. The finisher records the rejection in a memory-only, best-effort
  registry and `leadbay_import_status` reports `failed` with the backend's own
  message; a restart just falls back to the old "still committing" reading.
- **A completed dry run says so.** Polling with `dry_run:true` returned
  `complete` with no `result` and no discriminator, which the rendering
  contract turns into "✓ Import complete" — a lie about an import that
  committed nothing. The response now echoes `dry_run`.
- **A foreign `MCP_ROW_ID` column is not trusted as an identity.** A web-UI
  import's own file may carry that header holding blanks or one repeated value;
  treating those as our synthetic ids collapsed unrelated records onto one
  dedupe key. Only a `randomUUID()`-shaped value counts.
- **A canonical id is only published when the snapshot is complete.**
  `pendingLeadIds` can only speak for rows that were actually fetched, so while
  rows are missing an unvouched id might belong to one of them and still be
  MATCHING — downstream qualification would then act on a lead the wizard may
  yet re-match. `row_ids` propagates through `import_and_qualify` too.
- **Record dedupe falls back to the backend record id.** `importIds` need not
  name an MCP-created import; a web-UI one carries no `MCP_ROW_ID`, so keying
  only on that let a re-paged row count twice while another went missing — and
  a raw count matching `total_records` then read as a complete snapshot.

**Not shipped, deliberately:** the issue's criterion 6 asks to flip
`wait_for_completion` to default `false`. `http-server.ts:336` never passes a
`bulkTracker`, so on hosted that path throws `BULK_TRACKER_UNAVAILABLE` today
(product#4005) — the flip would break 100% of hosted imports. `BulkRecord` also
carries no org field, so a shared store on the multi-tenant hosted process would
make handles cross-tenant resolvable; that needs its own design. Parked behind
#4005. The degrade-to-running fix delivers the same benefit without it.

**Backend, not this repo:** the bimodal ~85s phase itself, and
`POST /leads/resolve`'s 81-95s slow mode (17 of 40 calls in the same session,
~24 minutes of wall clock). Issue criteria 1 and 2.

## 0.30.0 — 2026-08-19

Encode the **single-country rule** across every location-accepting surface
(product#3951). Each backend serves exactly ONE country, so a country name is
never a location criterion — whole-country intent means omitting the filter.
It never failed loudly: the admin-area index excludes country nodes
(product#3885), so the value trigram-matched a same-named town ("France" → the
commune of Francs, "United States" → Statesboro) and silently fenced the search
to one village. 3/3 sessions in the 2026-08-02 acceptance eval passed one; an FR
session burned six variants inside that fence before answering wrongly.

- **The descriptions were telling the agent to do it.** This was a
  contradiction sweep, not a missing rule. `followups-map.md.tmpl:47` said to
  pass `countries ("France", "United States")` and advertised
  `level 2 (country)` as searchable; `pull-followups`, `adjust-audience`,
  `new-lens`, `tour-plan` and the `leadbay_followup_check_in` prompt each
  legitimized country-level values. All rewritten to enumerate the levels the
  argument accepts. (Their `"Bavaria"` examples went too — a German region on a
  US/FR-only product.)
- **New shared snippet** `heuristics/single-country-universe.md`, included by 8
  tool descriptions and 5 prompts, carrying the rule, the measured failure and
  a recovery that branches — because one recovery is wrong for most cases.
  `country_locations[].axis` and `[].kind` decide it: only the HOME country on
  the INCLUDE axis means "omit the argument". A foreign country is unsupported,
  not unfiltered (re-running unfiltered answers a France question with US
  leads). On the EXCLUDE axis omitting is the inverse of the request. And when
  the argument carries a real place beside the country, only the country comes
  off — "keep the city, drop the country".
- **Mechanical rejection** in `_country-guard.ts` + `_country-names.ts` (full
  ISO 3166-1, English + French, no new dependency). Delivered in each tool's own
  idiom: composites return `status: "country_level_location"` and write nothing,
  `update_lens_filter` throws (including on `dry_run`), `list_locations` returns
  its empty envelope. Always the first statement of `execute`, so a bad value
  costs zero HTTP.
- **Exemptions that keep real prospecting working:** a `sovereign` field
  (Guadeloupe/Martinique/Réunion/Guyane valid on FR, Puerto Rico/Guam on US),
  region homonyms (Georgia the state, Jersey), and no foreign alpha-2 rejection
  on US, where 26 ISO codes double as state postal codes. Test sweeps over all
  50 states + postal codes and all 13 régions + 101 départements hold the line.
- **New audit** `test/audit/single-country-rule.test.ts` — asserts the rule is
  present in all 11 surfaces AND that none of them still says a country is a
  valid geo value, so the pre-fix state is unmergeable. It imports
  `COUNTRY_LEVEL_LOCATION` from core so a rename cannot leave the prose
  teaching a recovery for an error that no longer exists.
- **`WORKFLOWS.md`**: row 39 gains the country-is-not-a-territory rule and a
  success criterion; new row 52 "Country-wide scope — omit the location filter"
  with its contract.
- **Writes stop rather than retry.** On `new_lens` / `adjust_audience` /
  `update_lens_filter`, "drop the country and re-call" persists a lens change
  expressing a scope the workspace already has — the mutation WORKFLOWS.md
  forbids for this ask. The guard stops instead, but only when the country was
  the request's ONLY scope: a sector, a size or a real place elsewhere in the
  request is written as asked. An exclusion that is not a foreign country
  blocks the write outright whatever else survives, since dropping it persists
  the opposite of what was asked.
- **The country is read, never inferred.** `_meta.region` is the only evidence
  of which country a workspace serves; the user's wording is not, and
  `agent_memory_capture` must never store it — a wrong country there is
  replayed as remembered fact.
- **Two eval scenarios** under `test/eval/scenarios/country-scope/` (over- and
  under-deliver). Gated behind `EVAL=1`; CI protection is the audit. Run live
  on FR and US staging tenants: no country value reached a geo argument on any
  call, no lens was written, no country was captured to memory, and the country
  named in each answer traced to `_meta.region`.
- Freed the budget for the snippet by de-padding the `pull-followups` NEXT
  STEPS table (1109 chars of markdown column alignment, no content change) —
  that tool was 52 chars from the 17000 cap.
- **Closed four holes found reviewing the above.** (1) The wrapper list caught
  "across the United States" and "dans toute la France" but not the bare
  prepositions that carry most real traffic — `in the United States`,
  `en France`, `aux États-Unis`, `dans la France` — so the plainest spelling of
  the bug was the one that still reached `/geo/search`. (2) `EU` and
  `European Union` were covered while **`UE` / `Union européenne`** were not, on
  the one backend whose users write French; the French supra-national spellings
  are in now. (3) A `set_filter` carrying a country BESIDE a real criterion
  emitted one hint that said both "say the result covers everything" and "never
  as covering everything" — the `otherScope` flag was consulted only for writes,
  and both read call sites hardcoded `false` while computing the true value one
  line above. The hint and its sibling caveat now derive from one fact.
  (4) `leadbay_top_accounts_to_activate` rendered with `territory: "France"`
  opened with "pass it as `locations` on the lens" — an instruction to make the
  exact call the rest of the prompt forbids, ~35 lines above the branch that
  would have corrected it. The substituted sentence now carries the country
  caveat itself. The existing audit could not see any of this: it reads the
  UNRENDERED body, where that sentence is still `{{arg:territory_block}}`, so
  `test/audit/prompt-rendered-country-args.test.ts` renders each prompt with
  hostile arguments instead.
- The shared snippet documented a weaker rule than the code enforces: it named
  the write-stop for a country-only scope and for non-foreign exclusions, but
  not for a **foreign / supra-national INCLUDE carrying other criteria**, which
  the guard does stop. An agent following the snippet could pre-strip the
  country and save a home-country lens for a territory nobody asked about,
  never reaching the guard at all.

Known gap: a country passed as an already-resolved numeric admin-area id stays
invisible client-side (deciding whether id "1234" is a country needs a lookup
this client does not have). Tracked in product#3939; a test records it.

## 0.28.0 — 2026-07-31

Add **`leadbay_top_accounts_to_activate`** — a prompt that builds a ranked
account-**conquest** plan from Leadbay data: the accounts worth activating,
ranked by the strongest Leadbay signal (not by money — Leadbay holds no
invoicing data), each carrying one of six strategic motifs (SAUVETAGE / PLAN DE
COMPTE / MONTÉE EN GAMME / RÉVEIL / CONQUÊTE / SUIVI) that drives its phone
pitch and three-step checklist (product#3863). Renders via `leadbay_get_artifact_runtime` when the user
accepts the offered deck; the chat table stands alone otherwise.

**Leadbay-only by design.** Leadbay holds no invoicing data, so the plan ranks
by the strongest Leadbay signal and renders revenue-realized, per-family revenue
and cash-to-capture as **OMITTED — never estimated**, naming the extract columns
a cash-ranked version would require. An ERP-ingest mode was explored and
deliberately cut before release: it depended on inputs the platform cannot
supply (headcount for off-lens imported leads) and permissions it cannot assume
(`leadbay_import_leads` is admin-only). It is tracked separately.

**New `gates/data-provenance` snippet.** The deliverable mixes Leadbay
responses, public-registry counts and modelled assumptions in
front of a paying client, so every emitted number carries a provenance class
(`[ERP]` / `[LB]` / `[SIRENE]` / `[HYP]`), taint propagates through derived
figures, and a PROVENANCE LEDGER prints **before** the plan with un-sourceable
fields shown as OMITTED instead of quietly filled. The existing
`iron-laws/no-fabrication` covers identifiers only, so a derived quantity such
as a revenue figure could previously be invented without violating it.

Also adds `heuristics/account-activation-motifs` and `rendering/activation-card`,
plus WORKFLOWS entries 46–47 — the conquest plan and its fabrication guard.

The six motifs are SAUVETAGE / PLAN DE COMPTE / MONTÉE EN GAMME / RÉVEIL /
CONQUÊTE / **SUIVI**. The last is the honest label for an active known-pipeline
account when no order history is available: the four Pilotage motifs are
purchase-behaviour reads and CONQUÊTE means "not in the pipeline", so without
invoicing data an active Monitor row matches none of them. ERP order data would
split those rows into the Pilotage four.

Fixes a stale `server.json` npx pin that tracked the `0.25` line while the
package was on `0.26`, and bumps the `.claude-plugin` bundle off `@leadbay/mcp@0.13`,
which predated `leadbay_get_qualification_questions`,
`leadbay_scan_portfolio_signals` and `leadbay_get_artifact_runtime`.

## 0.27.0 — 2026-07-31

Consent-gated friction reporting (#171) — see that PR for detail. Released from
`main`; this entry is a placeholder so the version line is unbroken.

## 0.26.0 — 2026-07-27 (tagged, never published)

Makes **`leadbay_build_campaign`** autonomous: runs to a target `count` with no
confirm gates, and accepts explicit `job_titles` for buyer enrichment (#169).
The version was bumped and `mcp-v0.26.0` tagged, but the npm publish did not
complete — npm's latest remained `0.25.0`. Documented here retroactively;
`0.27.0` supersedes it and carries these changes forward.

## 0.25.0 — 2026-07-13

Add `leadbay_set_telemetry` — an in-product control to enable, disable, or check
product-usage telemetry (product#3879). Telemetry stays ON by default (opt-out).
The preference is stored per-user on the Leadbay account (via `POST
/users/telemetry`, read from `GET /users/me`). On the **hosted/web connector** a
user who opts out has their telemetry suppressed per-request. A **local/stdio
install** decides telemetry at process start from `LEADBAY_TELEMETRY_ENABLED` and
does NOT consult the account flag, so local opt-out also requires setting that
env var (the tool's copy says so). Requires the backend `telemetry_enabled`
field (leadbay/backend) + api-specs contract.

## 0.24.2 — 2026-07-13

Release bump to publish a fresh hosted HTTP-server image carrying the 0.24.1
telemetry fix (the hosted `mcp.leadbay.app` endpoint deploys from the release
image, separately from npm). No code changes.

## 0.24.1 — 2026-07-10

Fix PostHog tool-call events silently dropped (product#3876). The hosted HTTP
server (`mcp.leadbay.app` — ChatGPT / Claude custom connectors) built its
per-request server without a telemetry handle, so every tool call over HTTP
produced zero events; only stdio logged. It now wires a process-level PostHog
client with per-request identity (correct multi-tenant attribution, no identity
latch), flushes on SIGTERM/SIGINT (Fly redeploys), and emits a startup event.
Also hardens the stdio path: `shutdown()` now flushes buffered events when
identity never resolved, and stdio uses `flushAt:1` so short sessions deliver
promptly.

## 0.24.0 — 2026-07-08

Enrichment stays active until done, then self-reports — no reprompt (product#3866).

- **`leadbay_enrich_titles` / `leadbay_bulk_enrich_status`** — after launching an async enrichment the agent now stays active in the same turn, polls `leadbay_bulk_enrich_status` until the job is done (`all_done`, or a spaced-poll plateau for contacts the backend can't resolve), then reports the resolved emails/phones + refreshed quota on its own. Previously the agent ended its turn on the launch ack and the user had to reprompt to get results.
- **`leadbay_bulk_enrich_status`** — the notification fast path now returns per-lead contacts on any `include_contacts` read (not only when terminal), so a plateau report has real contact data; the legacy fan-out scopes progress to the requested titles and channels (email → `email`, phone → `phone_number`), so pre-existing enrichments of other roles/channels no longer inflate a run's counts or flip `all_done` early.
- **Bulk store** — `notification_id` now survives a file-backed reload (it was previously dropped by `validateRecord`, forcing the slower per-lead fallback on every status check in production).
- **Edge cases** — bounded polling everywhere (no infinite loops on unresolvable contacts), tracker-pending / aged-pending launches route safely (relaunch is server-idempotent), and explicit "don't wait / background" requests are honored.

## 0.23.14 — 2026-07-03

Registry: publish the hosted remote endpoint so GitHub Copilot / VS Code can offer Leadbay as a one-click remote connector (product#3861).

- **`server.json` `remotes`** — the registry entry now advertises the hosted Streamable HTTP endpoint (`https://mcp.leadbay.app/mcp`) alongside the npm package. Landed in code in 0.23.13 but needs this version bump to actually re-publish to the MCP Registry (the registry won't re-publish an already-published version). Auth is standard MCP OAuth (discovered via `.well-known`), so no header/API-key declaration.

## 0.23.13 — 2026-07-03

Directory-submission schema fix.

- **`leadbay_import_and_qualify` `dry_run` param** now declares a JSON-Schema type (`["boolean","string"]` + `enum [true,false,"preview"]`). It previously carried only a description, so connector-directory validators (ChatGPT Apps SDK / Anthropic) rejected it with "Parameters missing type: dry_run". No behavior change — those were already the only accepted values. Introspection confirmed `dry_run` was the only untyped input property across all tools.

## 0.23.12 — 2026-07-03

Claude Code install no longer fails with `unknown option '--scope'` / `--env` (product#3847).

- **`buildClaudeCodeAddArgs` (installer)** — the `claude mcp add` command built its subprocess tail with a `-p` **short** flag (`-- npx -y -p @leadbay/mcp@latest leadbay-mcp`). Even after the `--` separator, `-p` leaks back into Claude Code's own Commander parser on current CLIs, which then rejects an *earlier* option (`--scope`, or `--env`) with a misleading `unknown option` error — so the install aborted with exit 1. The tail now uses the `=`-joined long form `-- npx -y --package=@leadbay/mcp@latest leadbay-mcp`: the package spec must stay flagged and the `leadbay-mcp` bin named explicitly (the package declares several bins and none is `mcp`, so a bare `npx -y @leadbay/mcp@latest` would die at launch with `could not determine executable to run`), while `--package=…` is left untouched by Claude Code's parser. Verified live against `claude 2.1.199`: install → registered, uninstall → removed.

## 0.23.11 — 2026-07-02

Windows `.dxt` sign-in now opens the browser reliably (product#3839).

- **`openInBrowser` (Windows)** — the auto-open resolved the moment `cmd.exe` was *created*, before its internal `start` builtin actually handed the URL to the default browser. So a silent no-op (no default-browser protocol association, a locked-down shell / AppLocker, a corrupt `HKCR\http\shell\open`) went undetected: `browserOpenFailedAtBootstrap` stayed false and the user was told "a browser may have opened" when nothing did. On Windows we now wait (bounded — 800ms for `cmd start`, 1200ms otherwise) for the launcher's **exit code**, treat a non-zero exit as failure, and fall through to `rundll32 url.dll,FileProtocolHandler` (no command interpreter — the same ShellExecute path Explorer uses, with an honest exit code) and finally PowerShell `Start-Process`. When every launcher fails, the `AUTH_REQUIRED` envelope honestly says the browser couldn't be opened and shows the clickable sign-in link. The #3801 `&`-quoting `cmd start` candidate is unchanged and still tried first. macOS/Linux keep resolve-on-`spawn` (the #3805 headless-hang fix — those launchers are the hand-off).
- **`bin.ts` shutdown** — the `browserOpenInFlight` teardown wait rose 1.5s → 3s so the multi-launcher Windows walk can finish dispatching before exit (the sibling bootstrap wait already allows 4s; the surfaced sign-in link is the fallback either way).

## 0.23.10 — 2026-07-01

A freshly-created lens no longer reads as "empty" (product#3833).

- **`leadbay_new_lens`** — creating a lens with criteria queues an asynchronous backend wishlist rebuild, so an immediate `leadbay_pull_leads` can read empty for a few seconds while it computes. The `created` result now carries `computing_wishlist: true` and a "leads stream in — pull in ~30s" message so the agent waits instead of reporting an empty lens. A criteria-less clone inherits the base lens's leads immediately (`computing_wishlist: false`).
- **`leadbay_pull_leads`** — on an EMPTY page where the backend reports the lens is still computing (`computing_wishlist`/`computing_scores`), `next_steps` now carries a single "Re-pull in ~30s" option (`kind: repull_computing`) instead of `null`, so the agent renders a wait-and-retry widget rather than "no leads." An empty page with nothing computing still returns `null` (a genuinely empty / over-narrow lens — no fabricated leads).
- Confirmed against `leadbay/backend` (`POST /lenses/:id/filter` → `queueRefreshJob` → `RefreshLens`) and a live probe on `api-us`: a bare clone shows leads instantly; a filtered clone shows `computing_wishlist: true` then settles within ~1–30s.

## 0.23.9 — 2026-06-27

MCP data plane now targets backend **/1.6** (was /1.5).

- **`client`** — every request path is built from a single `API_VERSION = "1.6"` constant (exported as `API_PREFIX`). `LeadbayClient` (request / requestVoid / requestRawBinary + mock-mode paths) and `bin.ts`'s login probe all reference it, so future backend version bumps are a one-line change. Verified live on `api-us` before shipping: `/1.6` mirrors `/1.5` across the full route surface probed (monitor, lenses, auth/login, kpi, geo) — identical status on every call. Tool-description prose and route-doc comments updated to /1.6 to match.

## 0.23.8 — 2026-06-26

Installer no longer hangs in Claude Cowork / headless terminals (product#3805).

- **`installer`** — the guided installer awaited a detached browser-opener that resolved only on the child's `close` event, which a detached process often never emits; with no overall timeout the process dangled until the host (Claude Cowork) killed it ("running… then timeout", intermittently). It now reuses the hardened `openInBrowser` (resolves on `spawn`, not `close`), no longer blocks GUI startup on the browser open, and bounds the run with a watchdog that exits cleanly with hosted-MCP instructions instead of hanging. The installer always tries to open the browser — it never guesses "headless" and refuses to start.
- **Docs** — README documents adding Leadbay on **Claude web / Cowork web** as a custom Connector (`Customize → Connectors → Add custom connector`, URL `https://leadbay-mcp-prod.fly.dev/mcp`; OAuth runs in-app), and the `claude mcp add --transport http` equivalent. Stale `@0.16` install snippets corrected to `@latest`.

## 0.23.7 — 2026-06-25

Field-sales tour always renders the map (product#3779).

- **`leadbay_plan_tour_in_city` / `leadbay_tour_plan`** — a plain-language tour intent ("I'm visiting Jacksonville in 3 days — who should I go see?") now deterministically routes to the tour tool (no longer leaks to `leadbay_pull_leads`), builds the mixed tour (known accounts + fresh prospects), and **proactively offers to plot it on a map** — every run, without the user asking. On acceptance it renders via `places_map_display_v0` (or the place-card carousel on hosts without the widget).
- **Server-shaped `map_locations[]`** — leads are pre-shaped server-side (`{name, address, latitude, longitude, notes}`, badge in `notes`) plus a `map_summary`, so the agent never hand-builds the widget payload and passes coordinates through verbatim (no fabricated pins/addresses). Each stop is badged ★ Customer / ★ Qualified / ✦ New from real history fields (`epilogue_status` / `last_prospecting_action_at` / `last_monitor_action_at`). Guards against a `"null"`-string contact name.

## 0.23.6 — 2026-06-25

Geographic filter on Discover lenses (product#3759).

- **`leadbay_new_lens` / `leadbay_adjust_audience`** now accept `locations` / `exclude_locations` — a geographic dimension on the Discover lens, mirroring the sector path. Free text (`["Indre-et-Loire"]`, `["Texas"]`) auto-resolves via `/geo/search`, or pass admin-area ids directly. Resolution happens first: ambiguous/unresolved text returns `ambiguous_locations` and the lens is **not** mutated (no half-built lens). Resolved ids merge into the lens filter as a `location_ids` criterion. Unblocks the "scope a territory → net-new accounts there" cockpit workflow — geography was previously settable only on the Monitor side.

## 0.23.3 — 2026-06-24

- **Release plumbing only** — no functional change. First release on the updated CI that also publishes fixed-name `leadbay-latest.dxt` / `.mcpb` assets, so the docs can link a stable `…/releases/latest/download/leadbay-latest.dxt` that always resolves to the current version.

## 0.23.2 — 2026-06-24

- **Installer GUI shows the version** (product#3799) — the installer and uninstaller GUI cards now display the MCP version (e.g. `v0.23.2`) as a small muted-grey footer. Sourced from the build-time `__LEADBAY_MCP_VERSION__` define, so it tracks `package.json` with no manual upkeep.

## 0.23.1 — 2026-06-22

Retrieve + modify qualification questions and CRM custom fields over MCP (product#3768).

- **`leadbay_get_qualification_questions`** (new, always-on read) — the org's AI-agent qualification questions (the criteria every lead is scored against), with the caller's `is_admin` flag. Fetches the questions endpoint directly, so a transient backend/auth failure surfaces as an error rather than a false "none configured".
- **`leadbay_set_qualification_questions`** (new write) — add / remove / replace the org's questions. Reads the current list and posts the full result; enforces the backend's 5-question cap; **any change that drops an existing question requires `confirm:true`** (gated on the actual removed set, so a same-count swap still confirms).
- **`leadbay_get_lead_custom_fields`** (new, always-on read) — the CRM custom-field VALUES stored on one lead (`{id, name, type, value}`), distinct from the definitions catalog in `leadbay_list_mappable_fields`. Fires `LEAD_SEEN`.
- **`leadbay_update_custom_field` / `leadbay_delete_custom_field`** (new writes) — rename/retype a field in place, or delete it (delete requires `confirm:true`). Config is sanitized per type (and a stringified config is parsed) so the backend's strict deserializer never rejects it; the input schema advertises `object | string | null`.
- Modify tools are admin-scoped server-side (every user is admin of their own org). Requires backend leadbay/backend#1906 so OAuth tokens are accepted on the org + custom-field routes.

## 0.23.0 — 2026-06-21

Guided campaign builder.

- **`leadbay_build_campaign`** (new prompt + auto-generated Claude skill) — one guided on-ramp from "build me a campaign" to a ready-to-work campaign: discover on the active lens → qualify → pick an ICP pool → enrich the BUYER PERSONA of the user's product (a sales tool's buyer is the revenue org — VP/Head/Dir Sales, BD, CRO, CMO, growth — not whoever is most senior) → create the campaign → render the `leadbay_campaign_call_sheet` view → hand off to `leadbay_work_campaign`.
- **Coverage guarantee** — drops/swaps any lead with no enrichable buyer-persona contact for the highest-score in-ICP lead that has one (never trading ICP for coverage), and a ⚠ flag for suspect emails (domain ≠ company, or a contact on >1 lead). Polls enrichment to completion before rendering.
- Pure orchestration of existing composites — no new tool. Eval workflow #34 added; tuned over 4 live eval cycles (campaign quality 3/7 → 11/11 leads with a real revenue buyer reachable).

## 0.22.0 — 2026-06-20

Headless artifact SDK + two always-on read tools, so the user's Claude (cowork) can build interactive HTML artifacts that call Leadbay.

- **New package `@leadbay/components`** — vanilla view-models (`lb.field` / `action` / `resource` / `list`, plus domain helpers `outreach`, `note`, `like`/`dislike`, `leadHistory`, `leadProfile`, `callList`, `enrichment`, `teamActivity`) that own a control's data lifecycle: populate-from-API, value/loading/error, validation, polling, request sequencing, a 30s call timeout, and the `report_outreach` verification + `_triggered_by` footguns. The agent owns 100% of markup; the library renders nothing. The build emits a minified runtime into core; a `components:check` drift guard fails CI if the committed runtime goes stale.
- **`leadbay_get_artifact_runtime`** (always-on read) — returns the runtime string + a usage guide the agent reads to assemble an artifact. Lives in `tools/` (granular-shaped) so a kit fetch carries no `_triggered_by` mandate.
- **`leadbay_team_activity`** (always-on read) — per-rep activity leaderboard + activity trend for a look-back window, wrapping `/kpi/users` + `/kpi/trends` (the web Dashboard-Manager data). Admins get the whole org; non-admins are scoped to themselves by the backend.

Validated by agent-dogfood tests: an independent agent builds a working cold-call sheet + a manager dashboard from the usage guide alone, and jsdom asserts the real tool calls fire with the right args. (Core 0.8.4, components 0.3.1.)

## 0.21.3 — 2026-06-19

Kills the 401 startup "reconnect Leadbay" hallucination — the assistant told users to re-authenticate on a connection that actually worked (product#3761). Fixed at every layer that produced or surfaced the spurious 401:

- **`leadbay_account_status` withholds an unreadable quota from the payload entirely** (the actual source the user hit): `account_status` fans out `/users/me` (identity, succeeds) + `/organizations/{id}/quota_status` (quota), and for an org with no billing plan (`plan: null`) the backend's `quota_status` returns **401** on the very token that just succeeded. The old code surfaced that as `quota_error: {code: AUTH_EXPIRED, http_status: 401}`, and both the tool description and the `quota_error` schema told the agent *"on 401/403 tell the user to reconnect"* — so a perfectly-authenticated user was told to reconnect, every time, on a plan-less org. A 401/403 quota failure is now dropped from the response before the agent can see it (only logged); a genuine non-auth failure (500 / network) still surfaces as `quota_error` so the agent can honestly say quota is unreadable. Guidance alone was leaky — an agent still hedged *"quota had a hiccup"* — so withholding it is the only thing the agent literally cannot parrot. Locked by `account-status-quota-401.test.ts`.
- **`leadbay_account_status` gates the active lens on the trigger text, and uses the name not the raw id**: the agent was volunteering the active lens unprompted and surfacing the bare numeric id (e.g. `40005`). The lens (id and resolved name) is now withheld from the payload unless the user's message mentions lens / audience / targeting / segment / filter — the verbatim `_triggered_by` slice is plumbed through to `ToolContext` so the composite gates on what was actually asked (prompt guidance alone leaked the lens unprompted in ~1/3 of live runs). When asked, it resolves `last_requested_lens` → `last_requested_lens_name` (best-effort via `/lenses`, ids string-normalized so the match never drifts string-vs-number), and the agent answers with the name, never the number. (Core 0.8.3.)
- **Hosted HTTP MCP — empty the 401 OAuth-challenge body**: the Fly connector answers an unauthenticated / expired `POST /mcp` with `401` + `WWW-Authenticate` — the RFC 9728 OAuth challenge that drives host sign-in / silent refresh (added in 0.21.0), correct and unchanged. The bug was the challenge's *JSON body* ("Sign in with Leadbay again."), which a spec-compliant client never reads but Claude's host surfaces to the LLM, which then told the user to reconnect even though the immediate retry succeeded. `sendChallenge()` now returns an empty 401 body; the status and the `WWW-Authenticate` header (incl. `error="invalid_token"` for expired) are preserved byte-for-byte, so no protocol signal is lost. Test: `http-auth-challenge-body.test.ts`.
- **Local (stdio) MCP — `TRANSIENT_401` server-instruction**: Leadbay bearer tokens don't expire on a timer, so the client already treats a GET 401 as a transient blip and auto-retries once; only a *persistent* 401 surfaces (as `AUTH_EXPIRED`). But the agent still occasionally read a lone 401 as a broken login and told the user to reconnect even though the next call worked. A new always-on server-instruction paragraph tells the agent a one-off 401 is a brief Leadbay-side hiccup the client already retried — retry silently, never turn it into a "reconnect" message, and surface it only if calls keep failing (offered to `leadbay_report_friction`). Retry count and the `AUTH_EXPIRED` code are unchanged, so a real logout/revocation still surfaces. Test: `server-instructions-transient-401.test.ts`.

## 0.21.2 — 2026-06-17

- **Early host shutdown no longer kills the OAuth flow mid-registration** (review P1): Claude Desktop's probe→teardown can close stdin within ~100ms — while the background bootstrap is still in region-probe/discovery/registration, before any browser-open exists. `shutdown()` now waits (bounded ~4s) on the whole bootstrap task, not just the browser-open promise, so the flow reaches the authorize-URL mint + open dispatch instead of dying early. Verified: stdin closed at 1s still produced `spawn OK` at ~2.7s.
- **Terminal bootstrap failures surface `AUTH_FAILED`, not a forever-"pending"** (review P2): a non-browser failure (region probe / discovery / registration / token exchange) with no sign-in URL used to leave tools reporting "a browser window should have opened…" indefinitely. The failure reason is now recorded and the gate returns `AUTH_FAILED` with the real error + restart guidance; it takes priority over a stale sign-in link.

- **Browser auto-open now reconstructs a missing `DISPLAY`/`WAYLAND_DISPLAY` on Linux**: a real Claude Desktop install log showed the spawned server's env had `DISPLAY=<unset> WAYLAND=<unset>` (the host strips them inconsistently — present on some launches, absent on others). Without a display var, `xdg-open` spawns "successfully" but can't reach the display server, so no tab opens — the silent install failure. `openInBrowser` now backfills the missing vars from `XDG_RUNTIME_DIR` (the `wayland-N` socket) and `/tmp/.X11-unix` (defaulting to `:0`) before spawning the launcher, and passes that env to the child. Already-set vars are left untouched; non-Linux is unaffected.
- **OAuth client registration is cached & reused** (the actual "nothing opens" root cause): bootstrap was registering a fresh Dynamic-Client-Registration client on every launch, and Claude Desktop's probe-restarts (several launches per install) blew past the backend's ~10-registrations/IP/hour cap — so bootstrap 429'd *before* building a sign-in URL. The registered `client_id` is now persisted per auth server in `~/.leadbay/oauth-client.json` and reused (loopback clients accept any 127.0.0.1 port per RFC 8252), so registration happens at most once and the 429 never recurs.

- **OAuth-on-install for the Claude Desktop `.dxt` no longer fails with "Unable to connect to extension server"**: the bundled stdio server ran the full interactive browser OAuth flow (up to 5 minutes) at startup, *before* answering the MCP `initialize` handshake — so Claude Desktop, which gives a launched extension only a few seconds to respond, timed out the connection and marked the server unreachable. The OAuth bootstrap is now **non-blocking**: the server answers `initialize` immediately with a real (tokenless) client, runs the browser sign-in in the background, and the first tool call returns a transient `AUTH_PENDING` envelope ("Signing you in to Leadbay — a browser window should have opened…") until the token lands. The moment the loopback callback completes, the token is set on the live client and the next tool call executes authenticated — no server rebuild, no restart.
- **Browser auto-open now survives Claude Desktop's install-time probe race**: a freshly-installed extension is probed with rapid connect→shutdown cycles (the first spawned process can live <100ms — confirmed in the install logs), which killed the process before the background OAuth flow reached the browser-launch step, so no tab opened on install even though it works on a stable session. The bootstrap now fires the browser-open the moment the authorize URL is known (tracked in an in-flight handle), and the shutdown path waits up to 1.5s for that spawn to dispatch before exiting — the detached launcher then survives our exit. Net: the browser opens on install. The clickable sign-in link remains as the backstop.
- **The sign-in link is also surfaced to the user instead of relying solely on auto-opening a browser**: the spawned `.dxt` stdio process frequently can't open a GUI browser at all — Claude Desktop strips `PATH` *and* `DISPLAY`/`WAYLAND_DISPLAY` from the child env, so `xdg-open`/`open` either `ENOENT`s or (worse) silently exits 0 without launching anything, leaving the user with no link and no error. The bootstrap now captures the live OAuth authorize URL and the gate returns it as a **clickable sign-in link** in the tool envelope ("Open this link to authorize Leadbay…"); the loopback listener stays alive in the background, so clicking it completes the flow and the next tool call is authenticated. The browser auto-open is still attempted (best-effort, now via absolute launcher paths `/usr/bin/open` / `%SystemRoot%\System32\cmd.exe` / `/usr/bin/xdg-open`) for environments where it works — but the surfaced link is the reliable path and no longer depends on it.

## 0.21.1 — 2026-06-16

- **CSV import no longer 400s on a lead status the agent didn't uppercase** (product#3745): `leadbay_import_leads` / `leadbay_import_and_qualify` forwarded `default_status` / `statuses` values verbatim to `POST /imports/{id}/update_mappings`, whose backend `MappingsPayload` decodes them as the strict, case-sensitive `LeadStatus` enum (`DEFAULT, INBOUND, UNWANTED, WANTED, LOST, WON`). A value like "Won" failed deserialization and the whole call 400'd with an opaque "JSON deserialization error" before any record committed — JM hit this trying to tag 179 companies as Won. The MCP now owns the canonical set and enforces it before sending: status values are matched case-insensitively to their enum member ("Won" → "WON"), an empty default means no default, and a genuinely unknown status returns a clear `IMPORT_INVALID_STATUS` error naming the valid values instead of an opaque backend 400. The two tools' input schemas now declare the enum.

## 0.21.0 — 2026-06-16

- **Hosted MCP now triggers OAuth sign-in in Claude Desktop / ChatGPT** (remote custom connectors): the Fly endpoint was not an OAuth-compliant resource server, so a remote client had nothing to discover, never prompted the user to sign in, and then surfaced a host-side "needs auth / token expired" state even though the user never had a token. The server now implements the MCP authorization spec (RFC 9728): it serves OAuth 2.0 Protected Resource Metadata at `/.well-known/oauth-protected-resource[/<resource>]` and answers an unauthenticated (or invalid/expired) `POST /mcp` with `401` + `WWW-Authenticate: Bearer ... resource_metadata="…"`. The client discovers the Leadbay authorization server (the existing regional backend used by `login --oauth`) and runs the browser sign-in. Tool requests auto-probe both regions, so a valid token routes correctly and a stale one re-prompts instead of erroring.
- **Region-pinned connector URLs**: OAuth discovery runs before sign-in and Leadbay tokens are region-scoped, so the region is encoded in the URL. US accounts use `https://leadbay-mcp-prod.fly.dev/mcp`; FR accounts use `https://leadbay-mcp-prod.fly.dev/fr/mcp`. The path only selects which authorization server the sign-in prompt points at. Permissive CORS + an `OPTIONS` preflight are served on the discovery and MCP endpoints for browser-based remote clients. README's remote-client section updated to document Claude Desktop and the per-region URLs.

## 0.20.1 — 2026-06-15

- **Triage board stays the first next-step option on a poor-fit batch** (`leadbay_daily_check_in`): when today's batch is an ICP mismatch (every lead AI-scored off-profile), the agent was demoting the interactive triage board below "refine audience" in the NEXT STEPS widget — the plain ordering rule kept losing to the agent's own leverage judgment ("the whole batch is junk, so lead with fixing the lens"). The workflow contract requires the named artifact to be the FIRST option. The ordering rule now holds the triage board at position 1 even on a mismatched batch; the mismatch is surfaced in the prose nudge and offered as a *later* "refine the lens" option, never by displacing the artifact. Verified 5/5/5/5 across 3 consecutive eval runs on an all-off-ICP batch (the exact case that defeated the weaker rule).

## 0.20.0 — 2026-06-15

- **Proactive update proposal on a fresh session** (product#3742): the auto-update check already ran at boot, but the resulting proposal only reached the user if the agent happened to call `leadbay_account_status` — which a fresh session rarely does, so the "newer version available" prompt was effectively invisible. The cached `update_available` block now also rides along on `_meta.update_available` of the **first ordinary tool result** of a session while an upgrade is pending, gated once-per-version so it surfaces exactly once. `leadbay_account_status` keeps carrying it as a top-level field. The server-instruction paragraph now tells the agent to surface the `ask_user_input_v0` prompt whenever it sees the field on *any* response.
- **Installer asset is now `.dxt`, not `.mcpb`**: the release-asset picker prefers the `.dxt` bundle (falling back to `.mcpb` only when a release ships no `.dxt`). The field is renamed `mcpb_url` → `install_url` across `update_available`, the `leadbay_acknowledge_update` result, and the persisted update-state — with forward-migration of the legacy `latest_known_mcpb_url` key so existing users don't lose their cache.

## 0.19.3 — 2026-06-15

- **New tool `leadbay_send_feedback`**: delivers a user-authored message to the same destination as the web app's "Send feedback" form — the team's Sentry feedback inbox (the website form calls `Sentry.captureFeedback`; there is no Leadbay API endpoint, so the MCP reuses its already-initialized `@sentry/node`). User-initiated ("send feedback / report a bug / tell Leadbay…"), or offered on a tool error and sent only on explicit yes. Distinct from the silent, agent-detected, PostHog-only `leadbay_report_friction`: feedback is explicit, user-authored, and reaches the team's inbox. Honest delivery — if the Sentry transport isn't available it returns `sent:false`, never a false success; Sentry is flushed after capture so the event actually ships; identity is attached when it resolves (anonymous fallback rather than dropping the message). Write-gated (`LEADBAY_MCP_WRITE=1`) since it sends data outward.

## 0.19.2 — 2026-06-10

- **Stop paging Sentry on a missing `_triggered_by`**: a composite tool called without `_triggered_by` is a recoverable agent mistake — the host just re-calls with the field set. The guard used to `throw` an `{error:true, code:"LAST_PROMPT_REQUIRED"}` envelope into the shared catch, where `isLeadbayBusinessError` matched it and fired `captureException`, auto-opening a top-priority Sentry/GitHub bug (product#3718) on every dropped field. The guard now returns the `isError` envelope directly. Behavior toward the LLM is unchanged (same text, same `isError`, same recovery hint), and PostHog visibility is preserved (`captureToolCall` + `captureCompositeCall` still fire `ok:false` / `LAST_PROMPT_REQUIRED`, so the mandate-ignore rate stays observable); only `captureException` is dropped.
- **`_triggered_by` is now an always-mandatory, auditable protocol field**: reframed from analytics-only to a required intent trace, collected on every composite call regardless of the telemetry setting (when telemetry is off the value is captured locally but never transmitted, so the opt-out is still honored). A new server-instruction mandate paragraph reinforces the JSON-schema field description that agents kept ignoring. The `<no user message>` magic-string sentinel is gone — agent-initiated calls (memory recall, scheduled run, retry) now pass the actual instruction being acted on, so the field is genuinely non-empty in every case.

## 0.19.1 — 2026-06-09

- **New tool `leadbay_scan_portfolio_signals`**: read-only bulk scan of a Monitor portfolio (or an explicit lead-id list) for a web-research signal. Ask "which of my leads have an M&A / funding / hiring signal since 2025" and get the matched cohort back in one call — a `GET`-only fan-out over cached `web_fetch` signals (no per-lead research loop, no AI-qualification quota burn), with a case- and accent-folded query and optional `since` date. The matched cohort is campaign-ready (feeds straight into `leadbay_add_leads_to_campaign`).
- **Signal-honesty guardrail**: the scan separates `not_researched[]` (no cached content) from "no match", so the agent can never claim coverage for leads it never read. Reinforced in `leadbay_pull_followups`, `leadbay_research_lead_by_id`, and the `followup_check_in` prompt: freshness fields (`stale_at`, `web_fetch_in_progress`, `fetch_at`) are not signal indicators, and portfolio-wide signal questions route to the bulk tool. Every error path stays honest — a 429 while paging the portfolio, a non-quota read failure, and a failed filter-store all surface partial coverage rather than reporting a confident empty result.
- **Agent-side gap-fill in the follow-up check-in**: PHASE 3b turns the coverage gap into a refinement loop — the agent names the gap, runs a targeted live web pass on only the `not_researched` / thin-signal leads, and folds findings back in clearly labelled as agent-sourced (not Leadbay-verified), with `leadbay_bulk_qualify_leads` offered as the durable path that writes the signal into the portfolio.

## 0.19.0 — 2026-06-09

NEXT STEPS, artifact, and scheduled-task offers now fire reliably as host widgets across Claude chat, Claude cowork/Desktop, and ChatGPT.

- **Deterministic `next_steps` on `leadbay_pull_leads`** — the server now returns a ready-made `{question, options[]}` object with the "Build an interactive lead triage board" artifact offer pinned at `options[0]` whenever the batch is non-empty (`null` when empty). The model renders it verbatim into the host widget instead of re-deriving options from prose, which is where the artifact offer kept getting dropped.
- **Dual host-widget schema documented** — the next-step / choice widget differs by host: `ask_user_input_v0` (Claude chat / ChatGPT) takes plain-string options with `type:"single_select"`; `AskUserQuestion` (Claude cowork / Claude Code) takes `{label, description}` objects with a required short `header` and `multiSelect`, no `type`. Both are now documented (full forms in `host-widgets.ts`, compact form in the shared next-steps snippet) and made widget-mandatory-when-available.
- **WORKFLOWS.md** — added WF#16 (artifact proposal gate), WF#17 (recurrence routing gate — recurrence language runs the daily discovery check-in, not follow-ups), WF#18 (widget overdelivery guard).

## 0.18.2 — 2026-06-09

- **Release-pipeline fix**: align `packages/mcp/server.json` with `package.json`. `server.json` had been stuck at `0.17.2` since the 0.17.2 release, so the MCP-Registry publish step (`Verify server.json version matches package.json`) failed on every release from 0.17.3 through 0.18.1 — npm and the GitHub `.mcpb` shipped, but the registry listing silently went stale. Both `server.json` version fields (top-level + `packages[0].version`) now track `package.json`, and a new audit test (`test/audit/server-json-version.test.ts`) fails the build on any future drift instead of letting it surface only at release time.

## 0.18.1 — 2026-06-09

- **Quota rendering fix**: `leadbay_account_status` now renders the per-resource Daily / Weekly / Monthly **usage** table the API actually returns, instead of collapsing to "quota: null / no limits". Root cause: `quota_status` returns `count` (amount **used**) per resource per window with no cap field and a possibly-`null` `plan`; the old render hint tried to draw `used / cap` and gave up when there was no cap. The hint is now usage-only and explicitly warns that a missing cap / `null` plan is **not** "unlimited" or "no quota". `get-quota.ts` `outputSchema` corrected to the real `org` / `user.resources[]` shape (`{resource_type, count, window_type, resets_at}`, `count` = used), and a failed quota fetch is now distinguished from an empty quota.
- **Enrichment credit spend**: `leadbay_enrich_titles` surfaces the credit balance before and the actual spend after a run, reported discreetly rather than as a callout. Dropped the per-run "credits used" figure that conflated prior enrichments.

## 0.18.0 — 2026-06-08

Backend long-task notifications are now consumed by the MCP. When the user (or agent) initiates a bulk operation — contact enrichment, lead qualification, CSV / CRM import — the MCP listens to the backend WebSocket for the completion event and surfaces it on the agent's next tool call so prior outputs that depended on the now-finished data can be revised.

- **WS listener** — `wss://api-*.leadbay.app/ws/1.0?t=<ticket>` (ticketed via `GET /auth/ws?v=1.0`), reconnects with exponential backoff, REST catch-up via `GET /notifications` on every (re)connect and on cold start. Opt-out: `LEADBAY_NOTIFICATIONS_WS_DISABLED=1`.
- **`_meta.notifications` on every tool response** — terminal bulk-progress notifications appear on every successful tool call until the agent acknowledges them. Auto-expires after 24h locally to prevent unbounded growth in unattended automation.
- **`leadbay_account_status.notifications`** — same entries surfaced as a top-level field so the agent's daily-rhythm check-in sees them without reading `_meta`.
- **`leadbay_acknowledge_notification(notification_id, archive?)`** — new always-exposed tool. Posts `/notifications/{id}/seen` (default) or `/archive`, removes the entry from the local inbox. The agent calls this *after* it has revised prior outputs the just-finished work might have made stale.
- **Launch endpoints return `notification_id`** — `leadbay_enrich_titles`, `leadbay_bulk_qualify_leads`, `leadbay_import_leads`, and `leadbay_import_and_qualify` now read the canonical `notification_id` from `BulkLaunchResponse` / `BulkWebFetchResponsePayload` and persist it on the bulk tracker record.
- **`bulk_qualify_leads` now uses the selection-based bulk endpoint** — replaces per-lead fan-out so the backend creates a single progress notification per call. Per-lead error attribution is coarser at launch (leads outside `queued_ids ∪ skipped_ids` are tagged `not_queued`); the polling phase still pulls concrete per-lead state.
- **`bulk_enrich_status` fast path** — reads `bulk_progress` from the notification in a single REST call instead of fanning out `get_contacts` per lead. Falls back to the legacy per-lead path for records minted before this PR.
- **`qualify_status` surfaces `bulk_progress`** — bulk counters (success / failure / quota_hit) appear alongside the existing per-lead refresh. `quota_hit_count > 0` triggers an upgrade-or-wait hint.
- **Vocabulary**: "notifications" everywhere. Not "pending actions", not "tasks", not "async results" — matches the backend ADR (`docs/adr/notifications.md`).

## 0.17.3 — 2026-06-01

- **Lens management on the default surface**: lenses are now fully manageable from chat, no `LEADBAY_MCP_ADVANCED` needed.
  - `leadbay_manage_lenses` (write) — list your lenses, switch the active one, rename / set description, or delete (delete is confirm-gated and refuses the default lens).
  - `leadbay_new_lens` (write) — create a named lens with sector/size criteria in one call; previews and confirms before creating, and rolls back the created lens if applying its filter fails (no orphan half-built lenses).
  - `leadbay_adjust_audience` — new `lensName` param edits a lens **by name** without switching your active lens (edit-only).
  - `leadbay_list_sectors` (read, always-on) — the sector taxonomy lookup, so the agent stops guessing sector names.
- **Routing**: `leadbay_adjust_audience` and `leadbay_refine_lead_targeting` gained routing blocks so "create a lens" reaches `new_lens` (not `refine_prompt`) and "add X to my Y lens" fills `lensName` instead of editing the active lens.
- **Backend contract fixes** (were causing `400 JSON deserialization error` on lens create/edit, verified live): `POST /lenses` `base` sent as a string; `POST /lenses/:id/filter` sent as the unwrapped `{items:[…]}` body; `size` criteria carry both `min` and `max`.

## 0.17.2 — 2026-06-01

- **Linux installer fix**: skip Electron when no display is available (`$DISPLAY`/`$WAYLAND_DISPLAY` unset) and go straight to browser fallback — eliminates the double GUI URL on headless Linux terminals.
- **Browser open fallbacks**: on Linux, try `xdg-open` → `sensible-browser` → `google-chrome` → `chromium-browser` → `firefox` in order. If all fail, print a clear "Open this URL in your browser" message instead of silently doing nothing.
- **GitHub release notes**: release body now contains the actual CHANGELOG section for the version instead of "see CHANGELOG.md".

## 0.17.1 — 2026-06-01

- **Publish fix**: the `installer` npm bin (`npx -y -p @leadbay/mcp@latest installer`) was missing from the 0.17.0 tarball because the package was published before the installer wizard merged. This patch re-publishes with the correct bin entries.

## 0.17.0 — 2026-05-29

- **Lens extension** (product#3654): two new composites that expose the
  backend's agent-driven on-demand lens fill (backend#1844 / api-specs#205).
  - `leadbay_extend_lens` (write, gated by `LEADBAY_MCP_WRITE=1`) —
    `POST /lenses/{id}/extra_refill`. Translates the backend's 429
    `quota_exceeded` / 409 `refresh_in_progress` / 400 `no_valid_seeds`
    errors into routable `status` envelopes. On 429 the response carries
    `quota.used_today` + `quota.resets_at` and a message instructing the
    agent to surface three options to the user (smaller `extra_count` /
    wait for reset / upgrade plan).
  - `leadbay_list_lens_seed_candidates` (read) — internal scaffolding for the
    extend flow. Returns ranked candidate leads with rich signal
    (description, sector, tags, qq_answers, engagement). The agent picks
    3–5 seeds silently and chains to `extend_lens`; the user never
    reviews the seed list.
- New prompt `leadbay_extend_my_lens` orchestrates the four-phase flow:
  quota pre-check → silent seed pick → fire extend → react to status.
- `leadbay_account_status` description now mentions the per-org daily
  `LENS_EXTRA_REFILL` quota — pre-check it before calling
  `leadbay_extend_lens`.

## 0.16.2 — 2026-05-29

- **Tighter `_triggered_by` description on composite tools.** Live test of
  0.16.1 showed Claude shipping the literal string `"user"` as
  `_triggered_by` — technically non-empty, but useless for analytics. The
  description now explicitly forbids single-word labels (`user`, `agent`,
  `leads`, `request`, etc.), gives a GOOD/BAD example pair, and tells the
  agent to pass `<no user message>` when it's acting without a fresh user
  turn (memory recall, scheduled run, self-initiated retry) so the
  agent-initiated path is auditable instead of falsely attributed.

## 0.16.1 — 2026-05-29

- **`_triggered_by` is now MANDATORY on every composite-file tool** (the 28
  tools whose source lives under `packages/core/src/composite/`). Calls
  without it are rejected pre-dispatch as `LAST_PROMPT_REQUIRED`. Granular
  and agent-memory tools keep `_triggered_by` optional. Stronger
  description text on the schema property tells the agent to quote
  verbatim and strip secrets (`[REDACTED]`).
- **New PostHog event `mcp composite call`** with `last_prompt` attached
  (the trimmed verbatim user quote). Fires on every composite-tool
  invocation, success or error. Lives alongside the existing
  `mcp tool called` event — no regression on the broader pipeline. Lets
  dashboards filter user-language against composite outcomes without
  the 60-70% null rate the optional-everywhere `triggered_by` field
  carries on `mcp tool called`.

## 0.16.0 — 2026-05-29

- **Guided installer wizard**: Electron GUI (browser fallback) for install and uninstall. Detects Claude Code, Claude Desktop, Cursor, Codex, and ChatGPT Desktop automatically. OAuth sign-in built in — no token copy-paste.
- **Hosted MCP server**: Hono HTTP server at `https://leadbay-mcp-prod.fly.dev/mcp` for ChatGPT Desktop and other remote-MCP clients. Supports Streamable HTTP (`POST /mcp`) and legacy SSE (`GET /sse`, `POST /messages`).
- **Installer logic reorganised**: all install/uninstall logic moved from `src/bin.ts` into dedicated files under `installer/` (`install-claude-code.ts`, `install-json-config.ts`, `install-codex.ts`, `install-dxt.ts`, `install-wizard.ts`). `bin.ts` is now a thin MCP server entrypoint.
- **DXT extension removal**: when Claude Desktop 2026 DXT markers are detected, the installer removes the Leadbay DXT bundle (`Claude Extensions/local.dxt.leadbay.leadbay/` + registry entry) and writes `claude_desktop_config.json` as the authoritative config source.
- **macOS path fix**: `DetectedClient` now carries a `configPath` field; dropped the `detail.split(" ")[0]` pattern that truncated paths containing spaces (e.g. `~/Library/Application Support/Claude/...`).
- **SSE double-write fix**: `/sse` and `/messages` now return the `x-hono-already-sent` sentinel so Hono's Node adapter does not attempt a second header write after `SSEServerTransport` has already written headers.
- **Browser fallback uninstall fix**: `runBrowserFallback()` now opens the uninstaller GUI when `--uninstall` is passed, matching the Electron main process.
- **`@latest` pin**: all generated client configs now use `npx -y @leadbay/mcp@latest` instead of a hardcoded minor version.
- **OAuth login** (`leadbay-mcp login --oauth`): browser-based Authorization
  Code + PKCE flow with Dynamic Client Registration (RFC 7591). No password
  ever touches the CLI. The resulting `o.<token>` is interchangeable with the
  legacy bearer token.
- Region is auto-detected via stargate GeoIP (`stargate.leadbay.app/1.0/user_info`)
  for OAuth. Pass `--region us|fr` to override if you're on a VPN or travelling.
- Pass `--staging` together with `--oauth` to point at `staging.leadbay.app` for
  testing.
- The Claude Desktop `.dxt` / `.mcpb` bundle now opts into OAuth bootstrap on
  first launch. The install dialog no longer asks for a bearer token, region, or
  backend URL; it only exposes the write-tools toggle.
- Existing email-password `login` flow is unchanged and remains available for
  manual installs and CI.
- **Pin bumps**: active `@leadbay/mcp@0.13` install/runtime references in docs,
  generated client config, and MCP Registry metadata now point at `@0.16`.

## 0.15.0 — 2026-05-27

- **Sentry observability**: every non-2xx Leadbay API outcome now lands in
  Sentry with the full envelope — `code`, `message`, `hint`, `endpoint`,
  `region`, `http_status`, `latency_ms`, `retry_after`, agent `triggered_by`.
  Previously business errors (NOT_FOUND, AUTH_EXPIRED, QUOTA_EXCEEDED,
  FORBIDDEN, LEAD_NOT_FOUND, etc.) only landed in PostHog, and even the
  unexpected throws that reached Sentry carried only a bare exception with
  `tool` + `organization` tags. A new `source` tag (`business` vs
  `unexpected`) plus per-event fingerprint `["mcp", tool, code]` keeps the
  Sentry issue list groupable.
- HTTP status now propagates through `LeadbayError._meta.http_status` so
  `API_ERROR` (catch-all unmapped statuses) is filterable by status in
  Sentry instead of collapsing into one undifferentiated bucket.

## 0.13.0 — 2026-05-21

- **Agent memory v1**: added always-on recall/capture/review tools backed by
  local append-only JSONL at `~/.leadbay/memory/{account_id}/`.
- Leads-touching tool responses now attach `_meta.agent_memory.summary` with
  the consolidated top signals unless `LEADBAY_AGENT_MEMORY=off` is set.
- Server instructions, prompt descriptions, and workflow prompts now teach the
  memory protocol, including capture of new taste signals and review-gated
  retractions.
- Added `agent-memory://summary` resource and PostHog events for memory
  capture/recall/prune.
- **Pin bumps**: every active `@leadbay/mcp@0.12` install/runtime reference in
  docs, generated client config, DXT, MCP Registry metadata, and Claude plugin
  metadata is now `@0.13`.

## 0.12.1 — 2026-05-21

MCPB hotfix for Claude Desktop.

- **Fix packaged-server startup**: the MCPB bundle now injects a Node `createRequire` shim before esbuild's ESM wrapper so CommonJS dependencies can still require Node built-ins such as `perf_hooks`. This fixes the Claude Desktop disconnect where the server exited during initialization.
- **Packaging guardrail**: `@leadbay/dxt build` now runs the staged `server/index.js --version` before zipping, and the smoke suite extracts the MCPB and completes a real MCP initialize/tools-list handshake.
- **Manifest refresh**: MCPB manifests now declare `manifest_version: "0.3"` and the smoke assertions match the current MCPB manifest spec.

## 0.12.0 — 2026-05-21

Campaign and field-sales workflow release.

- **Campaign workflows**: adds campaign creation/listing, add-leads, progression summaries, and a `leadbay_campaign_call_sheet` composite that returns phone-ready, LinkedIn-ready, and map-ready lead/contact payloads.
- **Agent routing + skills**: adds the `leadbay_work_campaign`, `leadbay_plan_tour_in_city`, and `leadbay_setup_team_prospecting` prompt/skill flows so agents start with readiness checks, route to the right workflow tool, and keep outreach reporting grounded in verified user action.
- **Progression accuracy**: contacted/already-contacted summaries now use outreach/prospecting signals instead of treating contact coverage as outreach completion.
- **Coverage**: adds workflow audits, prompt-eval coverage for `leadbay_work_campaign`, live campaign smoke coverage, and focused unit tests for the new campaign progression/call-sheet composites.
- **Pin bumps**: every `@leadbay/mcp@0.11` install/runtime reference in docs, generated client config, DXT, and Claude plugin metadata is now `@0.12`.

## 0.11.0 — 2026-05-20

In-server auto-update flow: the MCP server now self-polls GitHub releases (24h throttle, ETag-aware, in-flight guarded) and surfaces an `update_available` block on `leadbay_account_status` when a newer version is published — both at boot AND on every tool call, so long-running Claude Desktop sessions still pick up new releases without restart.

- **New tool — `leadbay_acknowledge_update`**: records the user's choice from the `ask_user_input_v0` prompt. `action: 'install'` returns the `.mcpb` download URL (Claude Desktop's native installer opens on click); `'remind_tomorrow'` snoozes 24h; `'skip'` permanently suppresses that version. State persists to `~/.leadbay/update-state.json` (0o600, atomic write, symlink-rejecting; mirrors `bulk-store.ts`).
- **Five new PostHog events** for the funnel + conversion: `mcp_update_check`, `mcp_update_prompted`, `mcp_update_install_clicked`, `mcp_update_dismissed`, `mcp_version_updated` (fires on the next boot under a newer `VERSION` constant — works regardless of how the user upgraded: `.mcpb`, npm, npx).
- **Routing instruction** appended to `buildServerInstructions`: when `update_available` is present on `leadbay_account_status`, the agent prompts via `ask_user_input_v0` with three options and routes the choice through `leadbay_acknowledge_update`. Opt-out: `LEADBAY_UPDATE_CHECK_DISABLED=1`.
- **Pin bumps**: every `@leadbay/mcp@0.10` reference across `bin.ts`, `server.json`, `README.md`, `packages/dxt/manifest.template.json`, `.claude-plugin/.../plugin.json`, and the root `README.md` is now `@0.11`.

## 0.10.1 — 2026-05-20

Documentation + version-pin sweep paired with hardening the release pipeline. No functional changes to the published binary.

- **Pin bumps**: every `npx -y @leadbay/mcp@<old>` reference in `bin.ts` (install command output, error hints, doctor instructions, generated client configs), `README.md`, `server.json` (MCP Registry manifest), `packages/dxt/manifest.template.json`, and `.claude-plugin/plugins/leadbay/.claude-plugin/plugin.json` is now `@0.10`. New installs land on the latest minor.
- **Release pipeline migrated to npm Trusted Publishers OIDC** (`.github/workflows/release.yml`): npm revoked Classic tokens on Dec 9 2025 and Granular tokens with the "Bypass 2FA" flag still hit known publish-rejection bugs ([npm/cli#9268](https://github.com/npm/cli/issues/9268)). The publish step now uses OIDC via the [Trusted Publishers binding](https://docs.npmjs.com/trusted-publishers) configured per-package on npmjs.com. Runtime bumped to Node 24 in publish jobs for the bundled npm ≥ 11.5 that speaks the OIDC handshake (Node 22's npm 10 can't, and self-upgrade via `npm install -g npm@latest` consistently breaks on the runner image).
- **Auto-release** (existing `.github/workflows/auto-tag.yml` — unchanged in this release, documented here): merges to `main` that bump `packages/mcp/package.json#version` automatically push `mcp-v<ver>` and dispatch `release.yml` on the tag, which publishes to npm + MCP Registry + uploads the .dxt to a GitHub Release. No manual tagging needed.

## 0.10.0 — 2026-05-19

First stable cut of the 0.10 line. Consolidates everything since 0.9.1: host-native widget rendering, structured routing schema, the top-up flow, like/dislike write tools, the `research_lead` split, and PostHog + Sentry telemetry. See dev-iteration commits for granular per-PR history; this is the npm-shipped consolidation.

### Host-native widgets + chat-native rendering (#42)

Iframe widget rendering via MCP Apps `_meta.ui` is **removed entirely** — it short-circuited Claude's native widget routing and never blended with chat themes. All tools now render via two surfaces only: (1) chat-native markdown (the canonical `RENDERING` block every tool description carries — tables, cards, chips, headings; inherits the chat's theme + dark-mode for free), and (2) Claude's three first-party widgets when the host exposes them: `places_map_display_v0` (≥2 locations / travel intent), `message_compose_v1` (outreach drafts), `ask_user_input_v0` (NEXT STEPS / clarifications). Same widget-routing pattern applies on ChatGPT via `_meta.openai/outputTemplate`. The `Tool.ui` field is removed from the `Tool` interface — DO NOT re-introduce it.

Hosts that auto-detect addresses in agent prose (Claude.ai web, cowork, Claude Desktop) now get fed per-lead blocks shaped for their Google-Place-card carousel — see the `leadbay_followup_check_in` "TRAVEL / IN-PERSON ROUTING" block as the canonical example.

### Structured routing schema in promptforge (#42)

Every user-facing tool description follows a new 5-section convention enforced by promptforge + audit tests:

```
[1] ## WHEN TO USE   ← auto-emitted from frontmatter.routing
[2] ## RENDER (quick) ← auto-emitted from frontmatter.rendering_hint
---
[3] <free-form body>
[4] {{include:rendering/…}}
[5] {{include:next-steps/…}}
```

Routing frontmatter is structured YAML: `triggers`, `anti_triggers` (with `route_to` cross-references), `prefer_when` decision hint, and ≥3 positive + ≥3 negative example messages per [Anthropic's skill-author guide](https://www.anthropic.com/engineering/equipping-agents-for-the-real-world-with-agent-skills). The audit (`routing-block.test.ts`) asserts the `## WHEN TO USE` block lands within the first 600 chars (the context-truncation window every host honors), anti-trigger `route_to` values resolve to registered tool names, and the example floor (≥2 of each) holds for every routed tool. Backfilled on the 7 user-facing composites in this release.

### Top-up flow + billing tools always-on (#42)

`leadbay_create_topup_link` (POST `/stripe/topup_checkout` → Stripe checkout URL) and `leadbay_open_billing_portal` are now **always exposed** in `compositeReadTools` (not gated behind `LEADBAY_MCP_ADVANCED=1`) because they're the canonical recovery path from a `QUOTA_EXCEEDED` wall. Without them, the agent would know about the wall but not the door out.

`QUOTA_EXCEEDED` error hints now explicitly offer top-up as the path that clears the throttle immediately (vs. waiting for the daily/weekly/monthly window reset). The new `QUOTA_AND_TOPUP_PARAGRAPH` in the server instructions tells the agent to OFFER top-up on every quota wall and, after the user signals they've topped up, RESUME the originally-failed call rather than gate-keeping on a stale `account_status` snapshot.

New composite `leadbay_followups_map` for travel / itinerary / state-level intent — handles NYC/SF/LA city aliases and a universal `city` arg that resolves state/country/region levels server-side.

### `research_lead` split (#43)

`leadbay_research_lead` / `leadbay_research_company` are replaced by a pair whose **input mode is in the name itself**:

- `leadbay_research_lead_by_id` — exact UUID lookup; rich composite carrying qualification + signals + firmographics + two-tier contacts + unified `recent_activities` + engagement counts + `web_insights_fetched_at` + `_meta`. The `_meta.has_reachable_contact` flag is the one-shot signal that drives NEXT STEPS (false → propose `leadbay_enrich_titles`; true → propose `leadbay_prepare_outreach`).
- `leadbay_research_lead_by_name_fuzzy` — thin wrapper that resolves a `companyName` against the active lens's top-50 wishlist by substring (highest score wins), then delegates to `_by_id`.

Routing collapses to a syntactic choice (UUID vs. name) instead of a semantic guess, which was the source of misroutes when an agent had both a name and a partial ID.

### `leadbay_like_lead` + `leadbay_dislike_lead` write tools (#41)

The thumbs-up / thumbs-down actions already available on the Leadbay website are now MCP tools. Agents can send positive and negative lead signals back to the Leadbay scoring engine to improve future batch quality.

- `leadbay_like_lead` — POSTs to `/leads/{id}/like`. Fires on "this one looks good", "thumbs up", "I like this".
- `leadbay_dislike_lead` — POSTs to `/leads/{id}/dislike`. Fires on "not relevant", "wrong industry", "thumbs down". Distinct from `leadbay_set_pushback` (temporary deferral, not a permanent negative signal).

Both ship in the default write surface (no `LEADBAY_MCP_ADVANCED=1` required); gated by `LEADBAY_MCP_WRITE=1` (default ON since 0.3.0). Descriptions carry the new structured `routing` + `rendering_hint` frontmatter.

### PostHog + Sentry telemetry (#44, closes #3631)

Every tool invocation now fires an `mcp tool called` event to PostHog (same project as the frontend, project id 23333, EU instance), with quota walls surfaced as `mcp quota hit` and successful top-up checkout-link generation as `mcp topup link created`. Unexpected throws (TypeError, network failures, parse bugs) report to a new MCP-specific Sentry DSN; expected `LeadbayError` envelopes (QUOTA_EXCEEDED, NOT_FOUND, AUTH_EXPIRED, FORBIDDEN, BILLING_SUSPENDED, API_ERROR) stay in PostHog only.

- **Identity by email**: PostHog `distinctId = me.email` so MCP events consolidate with web-app events under the same person. Person properties (`leadbay_id`, `leadbay_organization`, `leadbay_organization_id`, etc.) match the frontend's `usePostHog.tsx` shape. **Events are NOT anonymous** — explicitly stated in `--help`, the install banner, and README.
- **`$groups.organization` attached** so org-level rollups work out of the box.
- **Privacy**: we capture `tool`, `duration_ms`, `ok`, `format`, `bytes`, `error_code` — never tool argument bodies, response bodies, lead emails, or Stripe URLs (unit test enforces).
- **Opt-out as a first-class toggle**: `leadbay-mcp install` always writes `LEADBAY_TELEMETRY_ENABLED=true` into your client's env block (next to `LEADBAY_TOKEN` / `LEADBAY_REGION`), so MCP-client config UIs (Claude Desktop, Cursor) render it as a toggle the user can flip without editing files. Pass `--no-telemetry` to install with telemetry off, or flip the env value to `"false"` anytime. Accepted: `true|1|yes|on` (enable), `false|0|no|off` (disable), case-insensitive. Also disabled when `NODE_ENV=test`.
- **Override**: `LEADBAY_POSTHOG_KEY` and `LEADBAY_SENTRY_DSN` env vars override the baked-in defaults.
- **stdio safety**: both SDKs are configured to never write to stdout (the JSON-RPC channel). Sentry runs without its default integrations (no console capture) and shutdown is bounded at 2s to never block process exit.

## 0.9.1 — 2026-05-16

**B23 fix — prompts no longer override per-tool RENDERING blocks**: 0.9.0 shipped RENDERING + NEXT STEPS blocks on every composite tool description. But agents still rendered prose for the daily-leads workflow, because the orchestrating `leadbay_daily_check_in` prompt's Phase 3 directed motivational one-line summaries that "won" over the per-tool RENDERING block in pull_leads. Phase 3 is rewritten to defer to the canonical pull_leads table layout (score bars, three columns, hide-list) and to add a 2–4 sentence "Today's nudges" paragraph ABOVE the table for the 3 most-promising rows — never in place of it. The same pattern is applied to `leadbay_qualify_top_n` (Phase 3 re-pulls newly-qualified leads via pull_leads and renders the canonical table, with a "Standouts from this batch" line above) and `leadbay_research_a_domain` (Phase 2 renders the research-company-card layout for the deep-dive result, with a 2–3 sentence summary above).

**New `gates/defer-to-tool-rendering` snippet — architectural prevention**: a new gate snippet codifies the rule "the tool's RENDERING wins for the structural layout; the prompt's voice wins for the commentary that surrounds it." Included from every prompt that orchestrates a composite carrying a RENDERING block (`leadbay_daily_check_in`, `leadbay_qualify_top_n`, `leadbay_research_a_domain`, `leadbay_import_file`, `leadbay_prospecting_overview`, and the new `leadbay_followup_check_in`). A new assembler test enforces the gate include: any prompt whose `expected_calls` lists a composite with a RENDERING block MUST include the snippet — CI fails otherwise. Closes the door on future prompts quietly bypassing the contract.

**Discover-vs-Monitor routing fix (companion to B23)**: 0.9.0 shipped `leadbay_pull_followups` (the Monitor view) and `leadbay_pull_leads` (Discover wishlist) as parallel entry points but no orchestrator prompt parallel to `leadbay_daily_check_in` for the follow-up flow. When the user asked "leads I should follow up with today", no prompt auto-triggered and the agent freelanced — typically iterating pages of `pull_leads` filtering by `prospecting_actions_count > 0` (the wrong backend table). Four fixes:

- **New `leadbay_followup_check_in` prompt** — Monitor-view orchestrator paired 1:1 with `leadbay_daily_check_in` (Discovery). Triggers on "follow up", "already known leads", "leads I haven't contacted", "leads in [city]", "before my trip", "this week", "what's overdue", "who should I re-engage". Calls `leadbay_pull_followups` (never `pull_leads`), renders the canonical followups-table with a "Where to start today" paragraph above. Auto-emits as a Claude Code skill via the existing skills pipeline.
- **Narrowed `leadbay_daily_check_in` triggers** — `short_description` rewritten to scope to DISCOVERY phrasings ("best NEW leads", "what's new today", "let's prospect"); explicitly does NOT trigger on "what should I follow up on", "before my trip", etc. New failure_mode entry catches accidental routing into discovery from follow-up queries.
- **Anti-confusion guardrail in `leadbay_pull_followups` description** — names the specific failure mode ("iterating pages of `pull_leads` looking for rows with `prospecting_actions_count > 0` or `notes_count > 0` → STOP, wrong entry point") and points to `leadbay_followup_check_in` as the canonical orchestrator. The pair of backend tables doesn't share rows — a touched lead may age out of the new-leads queue entirely.
- **Routing-pair section in `leadbay_prospecting_overview`** — explicit mapping from user phrasing to orchestrator prompt, plus a hard rule never to call `pull_leads` directly for a follow-up query (or `pull_followups` for a discovery query).

**Server-side fix to the catalog filter**: the `buildPromptsCatalogParagraph` filter that drops bullets referencing unexposed `leadbay_*` tools now exempts prompt-name references too (prompts are always exposed). Previously a discovery bullet that pointed the follow-up flow to `leadbay_followup_check_in` would have been silently dropped from the catalog.

**Six new regression eval scenarios**: B23 rendering — `daily-check-in/rendering-table-contract`, `qualify-top-n/rendering-refresh-table`, `research-a-domain/rendering-card-contract`. Routing — `followup-check-in/routing-regression` (calls `pull_followups`, NEVER `pull_leads`), `followup-check-in/cross-mode-pivot` (recognizes the pivot offer to discovery), `followup-check-in/geo-followup` (handles geo without fabricating an `admin_area_id`). Picked up automatically by the existing eval framework via the per-prompt eval files; the new `leadbay_followup_check_in.eval.ts` runs all three.

**Files touched**: 1 new gate snippet, 1 new prompt (`leadbay_followup_check_in`), 5 modified prompts (`leadbay_daily_check_in`, `leadbay_qualify_top_n`, `leadbay_research_a_domain`, `leadbay_import_file`, `leadbay_prospecting_overview`), 1 modified tool description (`leadbay_pull_followups`), 1 server.ts catalog-filter fix, 1 new assembler test, 6 new scenario files, 4 modified/new eval files, 1 new invariants module, touchfile registry updated. No backend schema changes; no tool surface changes; same wire protocol as 0.9.0.

## 0.9.0 — 2026-05-16

**RENDERING + NEXT STEPS blocks in every composite tool description**: agents consuming composite tools today default to prose summaries when they don't know how to present the data. Tool descriptions now carry two new prescriptive blocks the agent reads verbatim — `RENDERING` (a recipe for how to present the response: table columns, glyph palette, link targets, fields to hide) and `NEXT STEPS` (an observation → suggestion table the agent picks 2–3 contextually relevant offers from, never reciting the whole menu). Lands on the seven highest-leverage composites: `leadbay_pull_leads`, `leadbay_research_company`, `leadbay_prepare_outreach`, `leadbay_bulk_qualify_leads`, `leadbay_import_leads`, `leadbay_import_and_qualify`, `leadbay_import_status`, `leadbay_list_mappable_fields`, `leadbay_resolve_import_rows`. The blocks add ~5–7k chars per description, so the audit's per-tool char budget was raised from 3500 → 12000 with a comment explaining the design tradeoff.

**Three new snippet categories in promptforge** — `snippets/rendering/` (response-shape recipes: `score-bar`, `pull-leads-table`, `research-company-card`, `prepare-outreach-brief`, `import-result`, `status-inline`), `snippets/next-steps/` (one observation→suggestion table per composite), and `snippets/linking/` (`contact-linkedin` for the priority chain → real `linkedin_page` → people-search fallback with `°`-flag; `company-socials` for the multi-platform `social_urls` pill row). Each composite's `.md.tmpl` now `{{include:rendering/...}}` + `{{include:next-steps/...}}` instead of inlining the rules.

**New iron-law `outcome-after-outreach`**: when the user reports outreach happened ("I sent it", "she didn't pick up", a forwarded email thread), the agent MUST (a) call `leadbay_report_outreach` with verification AND (b) ask about the outcome and set `epilogue_status` to one of the 4 canonical values. User-facing dialogue uses "outcome" not "epilogue"; "follow-ups" not "Monitor". Included from `prepare_outreach.md.tmpl` and the new prospecting-overview prompt. Closes the loop that was silently de-ranking every future follow-up suggestion.

**New `leadbay_prospecting_overview` prompt → Claude Code skill**: ships at `prompts/leadbay_prospecting_overview.md.tmpl` and auto-emits `SKILL.md` via the existing skills pipeline. Orients the agent to the two-entry-point workflow (discovery via `pull_leads` vs follow-up via the app's Monitor view), natural-language signal routing, the outreach loop, adaptive drafting based on connected outreach tools (Lemlist / Outreach.io / Salesloft / Apollo / HubSpot / Instantly / Attio / Amplemarket / generic), outcome-recording habit, snooze/pushback semantics, and the lens-pinning rule. Auto-triggers on Leadbay-related conversation; stays dormant otherwise.

**Composite output-shape fixes**:

- **B1 / B6 / B7 — contact `linkedin_page` is canonical and never the literal string `"null"`**: `pull_leads`, `research_lead`, `prepare_outreach`, and `get_lead_profile` all propagate `linkedin_page` on `recommended_contact` and every `contacts[]` entry, coercing the legacy `"null"` four-character string (a backend serialization bug) to real JSON null on the way out.
- **B4 — `leadbay_research_lead` outputSchema fix**: `firmographics.size` was declared as `string|null` while the composite returns `{min,max,low,high,label}`; `firmographics.location` was `string|null` while the composite returns `{city,state,country,full,pos}`; `firmographics.tags` items were `string` while the composite returns `{id,display_name,tag,score}`. All three corrected to match the actual `LeadSimplified` shape. Also tightened `social_presence` and `social_urls` declarations to typed objects (was `["object","string","null"]` etc.). The tool was unusable before this fix — every call rejected by MCP schema validation.
- **B8 — `recommended_contact_title` dropped**: this field duplicated `recommended_contact.job_title` everywhere it appeared (`pull_leads`, `research_lead`, `get_lead_profile`). Removed.
- **B12 / B15 — `leadbay_prepare_outreach` expanded `lead` block**: was a two-field stub (`{name, ai_summary, website}`); now includes `score`, `ai_agent_lead_score`, `split_ai_summary`, `location`, `size`, `phone_numbers`, `description`, `short_description`, `social_presence`, `social_urls`. The agent no longer needs a second `research_company` call to render basic context.
- **B13 — self-polling enrichment**: `enrichment.complete: boolean` added. The brief now re-fetches contacts ONCE after triggering enrichment, and exposes `complete: true` when the recommended contact has either email or phone. The agent just re-calls `leadbay_prepare_outreach(leadId)` (no `enrich`) to poll; no separate `leadbay_get_contacts` tool needed.
- **B16 — `additional_contacts_count`**: clearer name. `other_contacts_count` kept as a deprecated alias for one release.
- **B21 — recommended_contact shape standardized**: always emit the post-enrichment field shape (`contact_id`, `first_name`, `last_name`, `job_title`, `email`, `phone_number`, `linkedin_page`, `is_org_contact`) with nulls in un-enriched fields — no more shape-flipping between pre- and post-enrichment.
- **`pull_leads` augmented**: trimmed shape now includes `ai_summary`, `split_ai_summary`, `phone_numbers`, `social_presence`, `social_urls`. Agents can render rich tables without verbose mode.

**Audit budget raised**: tool description per-tool char cap raised from 3500 → 12000. The largest tool descriptions are now `leadbay_research_company` (~10.5k chars) and `leadbay_prepare_outreach` (~10.1k chars). The plan was designed knowing this cost — the new char budget is justified by what the agent now does without prompting (table layout, glyph palette, observation→suggestion menu).

**New composite `leadbay_pull_followups`** — the Monitor view (re-engagement workflow), distinct from `leadbay_pull_leads` (discovery). Wraps `GET /1.5/monitor?personal=&liked=&filtered=&count=&page=` plus `GET/POST /1.5/monitor/filter` (server-persisted FilterItem). Accepts a `set_filter: { criteria: FilterCriterion[] }` parameter that POSTs first, then re-pulls — the same store-then-apply mechanism the Leadbay app uses. The composite excludes leads with active pushback client-side (defense-in-depth — the backend likely already does this) and reports `total_excluded_by_pushback`. Status-badge derived in the rendering rule from existing fields (`epilogue_status` + `last_prospecting_action_at` + `new`) — no new backend field needed. Endpoints verified live via the discover-monitor-activate wiki page; backend handler is `MonitorRoutes.kt:getMonitor()` → `Database.monitor.findAll`.

**Two new granular tools — `leadbay_set_pushback` / `leadbay_remove_pushback`**: snooze a lead (or a bulk set, up to 1000) for 3 / 6 / 12 months. Wraps `POST /leads/pushback` and `POST /leads/remove_pushback` (mirrors the existing `/leads/epilogue` / `/leads/remove_epilogue` pattern). Accepts short labels (`"3"` / `"6"` / `"12"`) or the wire-format enum (`PUSHBACK_3` / `PUSHBACK_6` / `PUSHBACK_12`). User-facing dialogue says "snooze for N months" — never "pushback status". Pull_followups excludes leads with active pushback from its results until expiry. Available behind `LEADBAY_MCP_ADVANCED=1 + LEADBAY_MCP_WRITE=1` (granular write).

## 0.8.0 — 2026-05-15

**Prompts ship as Claude Code skills + initialize advertises the prompt catalog**: the six MCP prompts (`leadbay_daily_check_in`, `leadbay_research_a_domain`, `leadbay_import_file`, `leadbay_log_outreach`, `leadbay_qualify_top_n`, `leadbay_refine_audience`) now also emit as auto-discovered Claude Code skills under `.claude-plugin/plugins/leadbay/skills/<name>/SKILL.md` from the same `.md.tmpl` source. `{{arg:NAME}}` placeholders are rewritten in-place as natural-language extraction instructions because skills have no structured-argument system. The MCP server's `initialize` response now splices a `PROMPT_CATALOG_INSTRUCTIONS` paragraph into the `instructions` payload so UI-blind clients (Cowork is the prototypical case) learn the prompt set; bullets that reference a tool gated off by the current config are suppressed (preserves the iter-12 invariant that the system prompt never names a tool the agent cannot call). Plugin manifest bumped `0.6.2` → `0.6.3`.

**Daily check-in resilience against MCP per-call timeouts and lens shifts**: a real session of `leadbay_daily_check_in` failed in three correlated ways — a blocking `leadbay_bulk_qualify_leads` hit the MCP per-call timeout, the recovery re-pull silently shifted lens and discarded the EU batch, and ten parallel `leadbay_research_lead` calls produced `"Tool permission stream closed"` backpressure that the agent treated as terminal. All three are workflow-contract gaps. New reusable snippet `packages/promptforge/snippets/heuristics/long-running-tools.md` codifies four resilience rules: pin the captured `lensId` to every subsequent call, default `wait_for_completion:false` + `qualify_status` polling for bulk ops, serialize `leadbay_research_lead` fan-out (≤3 parallel), retry transient transport errors instead of replanning. `leadbay_daily_check_in` includes the snippet, adds `PHASE 0 — RESUME CHECK` so "continue from where you left off" does not restart, pins `lensId` in Phase 2, switches Phase 3's top-up to the async pattern with `lensId`, and serializes Phase 4. Three new `failure_modes` entries enforce the rules during evals. Belt-and-suspenders paragraphs added to three composite tool descriptions (`leadbay_pull_leads`, `leadbay_bulk_qualify_leads`, `leadbay_research_lead`) so ad-hoc tool use gets the same hints.

**Tests**: `packages/promptforge/test/skills.test.ts` enforces 1:1 `.md.tmpl` ↔ `SKILL.md` mapping with a byte-equal freshness gate, non-empty trigger-phrasing descriptions, no leftover `{{arg:…}}` in skill bodies, and that `PROMPT_CATALOG_INSTRUCTIONS` names every prompt with the direct-invoke fallback. `packages/mcp/test/server.test.ts` asserts default config's `instructions` mentions all six prompts and that read-only config drops `leadbay_qualify_top_n` (its description references the gated-off `leadbay_bulk_qualify_leads`).

## 0.7.1 — 2026-05-14

**Hotfix**: `packages/mcp/server.json` (the MCP Registry manifest) was still pinned at `0.6.3` and referenced `@leadbay/mcp@0.6` in three places. The 0.7.0 npm publish succeeded but the MCP Registry publish failed on a version-drift check. This release re-publishes 0.7.0's content with `server.json` bumped to `0.7.1` and the npm specifier updated to `@leadbay/mcp@0.7`. No functional change to the published package over 0.7.0.

## 0.7.0 — 2026-05-15

**Compile pipeline for prompts and tool descriptions**: every MCP prompt body and every Tool description is now authored as a `.md.tmpl` source file in the new `@leadbay/promptforge` workspace package and compiled into `prompts.generated.ts` / `tool-descriptions.generated.ts` at build time. Authors edit prose in one place; the generated TS modules are the bundle's source of inlined strings. No wire-format change: every consumer (Claude Desktop, Cursor, Claude Code, OpenClaw) sees the same MCP protocol shape — `tools/list`, `prompts/list`, `prompts/get` are unchanged. Backwards-compat: every tool name, every `inputSchema`, every annotation set, every `outputSchema` is preserved byte-for-byte. The descriptions themselves are rewritten (see below) — that is the visible change.

**PHASES + IRON LAWS + GATES in every prompt**: all 6 prompts (`leadbay_daily_check_in`, `leadbay_research_a_domain`, `leadbay_import_file`, `leadbay_refine_audience`, `leadbay_log_outreach`, `leadbay_qualify_top_n`) restructured into explicit phases, with byproduct gates the agent must emit before progressing (`COLUMN PRESERVATION PLAN`, `DECISION LOG`, `FINAL REPORT` for file import; `STOP — awaiting user decision` for daily check-in). IRON LAW lines codify non-negotiables (no fabrication of leadIds; verification source required before `report_outreach`; top-N triage with motivational framing for `daily_check_in`).

**All 60 tool descriptions rewritten through shared snippets**: 21 composite + 37 granular + 2 file-import companion tools migrated. Each description uses shared snippet partials (`{{include:headers/tool-when-to-use}}`, `{{include:headers/tool-when-not-to-use}}`, `{{include:headers/verification-required-if-write}}` for write tools). 58 of 60 fit under 1500 chars; the two longest (`leadbay_import_leads`, `leadbay_list_mappable_fields`) retain their load-bearing detail at ~3100 chars. Audit test enforces a 3500-char per-tool budget.

**Shared snippet library**: 11 partials live in `packages/promptforge/snippets/` (iron-laws, gates, heuristics, headers). Most reusable today: `heuristics/crm-record-link.md` (extends the previous HubSpot guidance to Salesforce, Pipedrive, Close, Attio with per-CRM `url_template` examples for EXTERNAL_ID custom fields) and `heuristics/consumer-email-domains.md` with the explicit "domain extraction is a key factor of match success" framing.

**Daily check-in prompt overhaul** (per user direction): triage scope widened from top-3 to **top-10**, preferring leads with a fresh `ai_agent_lead_score`; each summary now framed motivationally ("why prospecting this lead today might be a good idea"); auto-top-up via `leadbay_bulk_qualify_leads` when the batch is short; PHASE 4 now researches **every** promising lead and surfaces the recommended contacts; the agent ASKS the user before consuming contact-enrichment quota.

**Import-file prompt overhaul** (per user direction): explicit GOAL section at the top — the job is to maximize how many rows the Leadbay system can ingest and match. Two deliverables named: max-coverage column mapping + the user's original file augmented with a `LEADBAY_ID` column populated for confidently disambiguated rows. WHAT GOOD LOOKS LIKE section teaches the disambiguation bar positively; the "never pick from score alone..." guardrail remains as a hard rule.

**Test framework spine** (`packages/mcp/test/eval/`): full eval harness landed. Drives scripted Claude sessions against the in-process MCP Server, captures L1+L2+L3 Evidence (tool calls, transcript, invariants, judge scores), runs a mission-match judge that reads its rubric from the prompt's frontmatter, enforces an Evidence-pyramid completeness rule, supports atomic partial saves to `.context/evals/<run-id>.json`, computes budget-regression flags, ships a worktree-based drift detector for main-vs-branch comparison, and includes a tool-routing classifier eval with 70+ (intent → expected_tool) fixtures. Gated by `EVAL=1`; default `pnpm test` ignores it. Replay-by-default for backend HTTP (`EVAL_RECORD=1` opt-in) keeps the user's Leadbay quota safe by construction.

**Audit tests** (`packages/mcp/test/audit/`): five new T0 meta-properties enforced on every commit — every prompt has eval coverage, tool descriptions imported (never inline in `server.ts`), per-tool char budget, `leadbay_<verb>_<noun>` naming convention, snippet orphans + dead `{{include:...}}` refs. Snapshot regression test catches accidental prose loss in migrated prompts.

**Agentic file import prep** (carryover entry — work landed in the merged 0.6.4 PR but never got a published changelog entry; rolled forward here): `leadbay_resolve_import_rows` — a read-only resolver that calls the backend `/leads/resolve` endpoint for messy CSV-shaped rows, returns matched/ambiguous/unresolved candidates, can hydrate ambiguous candidates with active-lens profile facts, and emits `records_for_import` plus safe identity-only `mappings_for_import` for `leadbay_import_leads` / `leadbay_import_and_qualify`. Import mappings accept `LEADBAY_ID`, `CRM_ID`, and `SIREN` as resolver fields.

## 0.6.3 — 2026-05-12

**Async import schema fix**: `leadbay_import_leads` now declares both its legacy blocking result shape and its async kickoff shape (`{status: "running", handle_id, importIds, progress}`) in `outputSchema`, so Claude Desktop and other MCP SDK clients accept the fast handle response instead of rejecting `structuredContent`.

**Async qualification schema fix**: `leadbay_bulk_qualify_leads` now also declares its async kickoff shape (`{status: "running", handle_id, qualify_id, ...}`), matching the `wait_for_completion:false` behavior added for short MCP client transport timeouts.

## 0.6.2 — 2026-05-12

**MCPB install fix**: desktop extension bundles now use the current `manifest_version` field and remove unsupported manifest keys (`user_config.*.enum` and an internal note field) so Claude Desktop can preview and install the MCPB.

## 0.6.1 — 2026-05-10

**Distribution**: listed in the official MCP Registry as `io.github.leadbay/leadbay-mcp` (auto-published from CI via GitHub OIDC); installable as a Claude Code plugin via `/plugin marketplace add leadbay/mcp` then `/plugin install leadbay@leadbay-mcp`; submission packets prepared for the Claude.ai connector directory and Anthropic's curated MCPB extension directory.

**Registry verification**: adds `mcpName` to the npm package metadata so the MCP Registry can verify ownership of `io.github.leadbay/leadbay-mcp` against the published `@leadbay/mcp` package.

## 0.6.0 — 2026-05-08

Massive spec-coverage upgrade. Each tool declares **annotations** (`readOnlyHint` / `destructiveHint` / `idempotentHint` / `openWorldHint`); 17 composites + 12 highest-leverage granulars declare typed `outputSchema` + emit `structuredContent`. New surfaces: `prompts/*` (5 canned slash-commands), `resources/*` (`lead://`, `lens://`, `org://taste-profile`), `notifications/progress` (per-lead streaming on every long-running composite), `notifications/cancelled` → `ToolContext.signal` (bulk-store entries marked `cancelled` so subsequent status polls return `BULK_CANCELLED`), `elicitation/create` (refine_prompt clarification + report_outreach user_confirmed anti-poisoning).

**Hardening**: every `inputSchema` declares `additionalProperties: false`. Runtime conformance test pins `structuredContent` against `outputSchema` for every declarer (drift-catcher). Static-scan audit of every error-hint string asserts each names a recovery action. `report_outreach.verification` rejects extra keys at runtime (closes the SDK's nested-additionalProperties limitation). `score_0_to_10` deprecated alias of `boost_score` removed in 0.7.0.

**Token economy**: pagination payloads carry `has_more` + `next_page`. `research_lead` truncates large payloads with a `truncation_hint`. Per-tool opt-in `response_format: "json" | "markdown"` lets chat-rendering agents pick the cheaper render (research_lead first; pattern reusable). `LEADBAY_DEBUG=1` enables a per-tools/call observability line on stderr.

**DXT → MCPB**: bundle now publishes both `leadbay-X.Y.Z.dxt` (legacy) and `leadbay-X.Y.Z.mcpb` (new Claude Desktop format) for one cycle. Manifest `dxt_version` field stays for backwards-compat with current installers; field rename will follow Anthropic's spec when finalised. The two filenames have identical content; downstream installers can match either glob.

**Versioning + docs**: 0.4.0 / 0.6.0 bumps; README §3a "Spec primitives in action" adds wire-level JSON-RPC transcripts for every primitive; MIGRATION.md 0.5 → 0.6 walkthrough.

## 0.5.0 — 2026-05-04

### `leadbay_import_and_qualify` — new composite

End-to-end import + AI qualification in one call. Wraps `leadbay_import_leads` (chunking, mapping preflight, custom-field validation), then fans out `web_fetch` on every imported leadId, polls until each lead's qualification answers populate, and returns the results. When the wall-clock budget overflows, returns a `qualify_id` UUID handle for resumable retrieval via the new `leadbay_qualify_status` tool.

**Inputs** (mostly mirrors `leadbay_import_leads`):

- `domains` OR `records` — same shape as import_leads.
- `mappings.fields` and `mappings.custom_fields` — same as 0.3.0 import_leads. Custom fields surface as first-class via `leadbay_list_mappable_fields` (also new).
- `dry_run: "preview"` — special mode: uploads the CSV in dry-run and returns the wizard's per-column AI mapping hints + sample rows + custom-field candidates from the org catalog matched against unmapped column names by exact / case-insensitive / fuzzy-substring. NO ai_rescore quota consumed.
- `total_budget_ms` (default 900_000 = 15 min), `per_lead_budget_ms` (default 90_000), `per_phase_budget_ms` (default 300_000).
- `skip_already_qualified` (default `true`) — skips `web_fetch` launch on leads with a non-null `ai_agent_lead_score`. Saves quota.

**Outputs** (full mode):

```
{
  kind: "result",
  qualify_id: "<UUIDv4>" | null,
  import_ids: [...],
  imported: [{ leadId, domain?, name, rowId? }],
  not_imported: [...],
  qualified: [{ lead_id, qualifications: [{ question, score, response, computed_at }], qualification_summary, signals_count, ... }],
  still_running: [{ lead_id }],
  failed: [...],
  quota_exceeded: bool,
  skipped_already_qualified: [...],
  reused?: bool, seconds_since_original?: number,
  cancelled?: bool, budget_exhausted?: bool,
  region, _meta
}
```

`qualify_id` is persisted to `~/.leadbay/bulks.json` (30-day TTL, 5-min idempotency window) — same store as `leadbay_enrich_titles` but with a `kind: "qualify"` discriminator. Re-calling with the same records+mapping within 5 min returns the same handle (`reused: true`).

### `leadbay_qualify_status` — new resumable retrieval

```
leadbay_qualify_status({ qualify_id })
  → { qualify_id, status, lead_ids, qualified, still_running, ... }
```

Refreshes the per-lead state (`/web_fetch` + `/ai_agent_responses`) at call time. No backend mutation. Survives MCP restart.

### `leadbay_list_mappable_fields` — new discovery

```
leadbay_list_mappable_fields()
  → { standard_fields: [{name, description, mapping_value}], custom_fields: [{id, name, type, description, mapping_value}], region, _meta }
```

Lists every CRM field the agent can target in `mappings.fields`. Standard fields come from a static catalog with human descriptions; custom fields come from `GET /crm/custom_fields`. The `mapping_value` field is what the agent passes verbatim (e.g., `"CUSTOM.8"`).

### `leadbay_import_leads` 0.3.0 — `mappings.custom_fields` shorthand

In 0.2.0 the only way to map a custom field was `mappings.fields[col] = "CUSTOM.<id>"` (raw wire format). 0.3.0 adds `mappings.custom_fields[col] = <id>` (numeric) or `<name>` (string), resolved against `/crm/custom_fields` before the wizard sees it. New error codes: `IMPORT_CUSTOM_FIELD_UNKNOWN`, `IMPORT_INVALID_CUSTOM_MAPPING`, `IMPORT_CUSTOM_FIELD_NAME_AMBIGUOUS`, `IMPORT_CUSTOM_FIELD_CATALOG_REQUIRED`, `IMPORT_MAPPING_DUPLICATE_CUSTOM`. Catalog GET is suppressed when the mapping references no custom fields (saves a round trip).

### Bulk store schema widening

`BulkRecord` now has a `kind: "enrich" | "qualify"` discriminator. Old enrich rows (no `kind` field) default to `"enrich"` on read. Existing `leadbay_bulk_enrich_status` callers see no change. Cross-kind id queries are surfaced with the new `BULK_WRONG_KIND` error code that points the caller at the right tool.

### `not_in_lens` partition — terminate the silent infinite-poll

Both `leadbay_import_and_qualify` and `leadbay_qualify_status` now surface a `not_in_lens: string[]` array — lead ids that exist in the org (the wizard imported them) but are NOT admitted to the active lens. The backend's `queueAiRescoreForLead` is a no-op for these leads — they will never appear in `qualified[]`. Surfacing them in a distinct partition means the agent's poll loop terminates.

Discovered by iter-17 live e2e: imported 4 leads (Apple, Stripe, Datadog, GitHub) into a lens whose scoring rules only admitted Datadog. Datadog got `ai_agent_lead_score: -13` + 3/3 qualifications cleanly; Apple and Stripe sat in `still_running[]` indefinitely with no signal to stop polling. Now they land in `not_in_lens` with an actionable agent prompt: "either change the active lens or accept the lead won't be qualified."

`qualify_status` re-checks lens membership at each call — a lead added to the lens after the original `import_and_qualify` automatically migrates from `not_in_lens` back into the regular qualify pipeline on the next status call.

### Aligned with backend `/imports/{id}/leads` (PR #1801)

`leadbay_import_and_qualify` now sources the qualify-phase lead set from `GET /1.5/imports/{importId}/leads` (added backend-side 2026-05-06). This is the spec-prescribed source of truth — distinct lead ids the import touched (matched-existing AND newly-created). Replaces the per-record reconciliation pagination for the qualify input. Falls back gracefully to the per-record set when the endpoint is unavailable (older backend / 400 in_progress race).

Verified live: import 970bd47a-… (apple.com matched, salesforce.com uncrawled) → /leads returned `{lead_ids: [0a788a89-...]}` cleanly; qualify_id 08ad4555-… retrieved end-to-end.

### Monitor-membership disclosure

Both `leadbay_import_leads` and `leadbay_import_and_qualify` now flag in their tool descriptions that imported leads are NOT auto-promoted to the user's Monitor tab. Lens-scoring rules decide — only above-threshold leads get `in_monitor: true`. This was a real surprise discovered in production (journal entry `leadbay-monitor-lens-filter`, captured 2026-05-05). Surfacing it in the description prevents the agent from telling users "I imported your leads, check Monitor" — answer is the CRM-imports list, not Monitor.

### Stable qualification ordering + human_summary

Both `leadbay_import_and_qualify` and `leadbay_qualify_status` now sort `qualifications[]` by the org's `ai_agent_questions` catalog order — the same question appears at the same index across calls so LLM agents can position-index reliably. Catalog comes from `client.resolveTasteProfile()` (cached 10min). Falls back to alphabetical when the catalog is empty.

Each `qualified[]` entry also carries an optional `human_summary` string of the form `answered X/Y — <signal> on '<question>'[, <signal> on '<question>']` where `<signal>` is `strong positive` (score=20), `positive` (10), `neutral` (0), or `negative` (-10). Top-2 by absolute score. Saves the agent from reading every per-question response when it just needs the gist.

## 0.4.0 — 2026-05-04

### `leadbay_import_leads` 0.2.0 — custom field mapping

The MCP tool now drives the same CRM-import wizard the web UI exposes — pass arbitrary CSV-shaped records and tell Leadbay which column maps to which `StandardCrmFieldType`.

**Two modes** (pass exactly one of `domains` / `records`):

- **Mode A (existing, unchanged):** `domains: [{domain, name?}]` — synthesizes a 2-column CSV (LEAD_NAME, LEAD_WEBSITE) and uses the default mapping. Output shape is identical to 0.1.x: `{ leads: [{domain, leadId, name}], not_imported: [{domain, reason}], ... }`.
- **Mode B (new):** `records: [{Col1, Col2, ...}]` plus `mappings: { fields: { Col1: "LEAD_NAME", Col2: "LEAD_WEBSITE", Col3: "LEAD_SECTOR", ... } }`. The tool synthesizes a CSV from the union of record keys (sorted, deterministic) and POSTs the caller-supplied mapping to `/imports/{id}/update_mappings`. Output shape: `{ leads: [{rowId, domain?, leadId, name}], not_imported: [{rowId, domain?, reason}], ... }`. `rowId` round-trips your input row order; `domain` populated only when `LEAD_WEBSITE` was mapped and the value parsed.

Mappings.fields must include `LEAD_NAME` or `LEAD_WEBSITE` — the wizard's resolver needs at least one of those to find a lead. Other CRM fields (`LEAD_SECTOR`, `LEAD_LOCATION`, `LEAD_SIZE`, `EMAIL`, `CRM_ID`, `LEADBAY_ID`, `DEAL_CRM_ID`, `CONTACT_TITLE`, `LEAD_STATUS`, `LEAD_STATUS_DATE`) are passed through verbatim.

**Validation (records mode):** new typed error codes — `IMPORT_INPUT_CONFLICT` (both modes supplied), `IMPORT_MAPPING_REQUIRED` (no mappings.fields), `IMPORT_MAPPING_NO_RESOLVER` (no LEAD_NAME or LEAD_WEBSITE in mapping), `IMPORT_MAPPING_KEY_UNKNOWN` (mapping key absent from records), `IMPORT_RESERVED_COLUMN` (record or mapping key matches `MCP_ROW_ID` case-insensitively), `IMPORT_INVALID_COLUMN_NAME` (column name >128 chars or contains control chars), `IMPORT_INVALID_CELL_TYPE` (cell value is array/object — coerce to string before passing). null/undefined cells coerce to "", numbers/booleans coerce via `String(v)`.

**Security:** user-supplied column names now flow through the same `escapeCsvCell` (RFC 4180 quoting + formula-injection prefix) that data values use. Header injection vectors (`=`, `+`, `-`, `@`, `,`, `"`, newline) are neutered.

**Backward compat:** Mode A output shape unchanged — the new `rowId` field is records-mode only. Existing `domains: [...]` callers see no diff.

## 0.3.0 — 2026-04-29

Behavior-changing release: closes [product#3504](https://github.com/leadbay/product/issues/3504) end-to-end. Default-installed MCP server now matches its own system prompt out of the box, the `login` command never lands a bearer token in scrollback by default, and `claude mcp add` registers Leadbay at user scope so it's visible from any project.

### Coverage — composite write tools default ON

- **`LEADBAY_MCP_WRITE` default is now `"1"` (ON).** The composite write tools (`leadbay_bulk_qualify_leads`, `leadbay_enrich_titles`, `leadbay_refine_lead_targeting`, `leadbay_report_outreach`, `leadbay_adjust_audience`, `leadbay_answer_clarification`, `leadbay_import_leads`) are exposed by default. Set `LEADBAY_MCP_WRITE=0` (or `--no-write` on `install`) to disable them.
- **`SERVER_INSTRUCTIONS` is now dynamic.** The system prompt sent to MCP clients references only the tools actually registered on this instance. Read-only-mode agents receive a different prompt that omits the verification mandate and tells the agent to ask the user to enable writes if they request a config-mutating action.
- **`leadbay-mcp install --include-write` is a no-op (deprecated).** Writes are on by default. Pass `--no-write` for the inverse. The deprecation warning prints **before** the password prompt so it's not buried.
- **`LEADBAY_MCP_WRITE` value-vocabulary expanded.** In 0.2.x only `"1"` was ON; `"true"` / `"yes"` / `"on"` were treated as OFF. In 0.3.0 the parser accepts all of those as ON, and `"0"` / `"false"` / `"no"` / `"off"` as OFF. Unrecognized values default to ON with a one-shot stderr warning. **Existing operators using `=true` / `=yes` / `=on` will see writes flip ON.** See [MIGRATION.md](./MIGRATION.md).

### Login — no token in stdout

- **`leadbay-mcp login` default writes a 0600-mode credentials file.** The path resolves to `$XDG_CONFIG_HOME/leadbay/credentials.json` if set, else `~/Library/Application Support/leadbay/credentials.json` on macOS, `%APPDATA%\leadbay\credentials.json` on Windows, else `~/.config/leadbay/credentials.json`. Existing `~/.leadbay-mcp.json` files (0.2.x) are still honored on this run with a deprecation note pointing at the new path (no automatic file move).
- **`--unsafe-print-token`** restores the previous "print JSON config to stdout" behavior. The deprecated `--print-token` alias still works for one release with a deprecation warning.
- **Collision detection** — if the target credentials file already exists with a different `LEADBAY_TOKEN` or `LEADBAY_REGION`, `login` refuses without `--force` and tells the operator how to keep both files.
- **EACCES / EROFS / ENOENT** errors on the file write print actionable remediation pointing at `--write-config /tmp/...` or `--unsafe-print-token`.

### Scope — visible from any project

- **`leadbay-mcp install` now passes `--scope user` to `claude mcp add`.** This was Ludo's third complaint in #3504: the default `claude mcp add` is project-local, so a freshly-opened conversation from a different directory can't see the server. README §2 (Claude Code), §1 (install), and §4 (troubleshooting table) all reflect the user-scope recommendation.

### Migration

- Read [MIGRATION.md](./MIGRATION.md) for the value-vocabulary flip, the login default, and the install scope change.
- The legacy DXT manifest key (`leadbay_mcp_write`) is unchanged in shape — it now defaults `true` (ON) and feeds the same `LEADBAY_MCP_WRITE` env var. Users who explicitly set the toggle to `false` keep their read-only behavior; users who never touched it (the bug case Ludo hit) now get the new default ON.
- Tests: dropped the `SERVER_INSTRUCTIONS` const re-export. Tests now exercise `buildServerInstructions(exposedSet)` directly across the default/read-only/advanced matrices. New unit suites: `parse-write-env.test.ts`, `login-default.test.ts`, `install-flags.test.ts`.

### Known follow-up (0.4.0)

- `compositeWriteTools` will split into safer-tier (credit-spending: bulk_qualify_leads, enrich_titles, report_outreach, answer_clarification) and config-mutating-tier (refine_prompt, adjust_audience, import_leads), with audit-log + one-click undo on the config-mutating side. Tracked separately.

## 0.2.5 — 2026-04-28

New `leadbay_import_leads` composite write tool.

- **New tool: [`leadbay_import_leads`](https://github.com/leadbay/product/issues/3537)** — accepts a list of `{ domain, name? }` and returns Leadbay `leadId`s for the ones the crawler already knows. Output is naturally chainable into `leadbay_bulk_qualify_leads({ leadIds })` and `leadbay_research_lead`. Gated behind `LEADBAY_MCP_WRITE=1` (MCP) and `exposeWrite=true` (OpenClaw).

  **⚠️ Writes user state.** Internally wraps Leadbay's CRM-import wizard (the only domain-import primitive the backend ships today). Each call:
    - creates a row in the user's CRM-imports list (visible in the web UI)
    - touches onboarding state (`startFileless`, onboarding step → PROCESSING)

  Suitable for occasional automation. **Not** suitable for high-cadence (>5 calls/day) — the right primitive is a clean async-import-with-crawl backend endpoint, tracked as a follow-up issue (`leadbay/backend` — prolonged async import jobs).

  **Surface:**
  - Input: `{ domains: [{domain, name?}], dry_run?: boolean, per_phase_budget_ms?, total_budget_ms? }`.
  - Output: `{ leads: [{domain, leadId, name}], not_imported: [{domain, reason}], importIds, region, _meta }` where `reason ∈ malformed | no_match | uncrawled | ambiguous | internal_error | dry_run`.
  - `dry_run: true` runs preprocess only — skips the lead-CRM linking. The CRM-imports row still appears (the wedge can't fully eliminate it without backend changes), but the heavier side effect of committing matches is skipped.
  - 8 typed error codes (`IMPORT_PREPROCESS_FAILED`, `IMPORT_PROCESSING_FAILED`, `IMPORT_BUDGET_EXHAUSTED`, `IMPORT_NOT_TERMINAL`, `IMPORT_ADMIN_REQUIRED`, `IMPORT_BILLING_REQUIRED`, `IMPORT_PAGINATION_RUNAWAY`, `IMPORT_EMPTY_INPUT`) — every one carries `{ code, message, hint }`. Per-domain `not_imported.reason="internal_error"` covers irreconcilable rows from the wizard.

  **Limitations (v1):** uncrawled domains land in `not_imported` with `reason: "uncrawled"` — the tool does NOT create new Leadbay leads for unknown websites; the caller decides what to do. The backend follow-up will lift this.

  **Implementation notes:** preflight admin check (fails in <500ms instead of after a 30s wizard timeout), MCP_ROW_ID-based reconciliation (resilient to wizard URL canonicalization), client-side chunking at 100 domains per CSV upload, stabilization loop after `processing.finished` to avoid races where some records are still in `MATCHING|IMPORTING`, RFC 4180 quoting + formula-injection (`=`/`+`/`-`/`@`) prefix on every cell, AbortSignal plumbing returns `{cancelled: true, importIds, ...}` so callers can recover. New helper `LeadbayClient.requestRawBinary()` for the CSV upload — mirrors `request()` exactly (auth, semaphore, error mapping, `_lastMeta`, `LEADBAY_MOCK=1` mock-mode parity).

- **README**: new `## Write tools (LEADBAY_MCP_WRITE=1)` section with the import quickstart.

## 0.2.4 — 2026-04-22

Claude Desktop 2026 compatibility + install UX polish. Also publishes the `refine_prompt` `/user_prompt` wire-key fix that landed on `main` in 0.2.3 but never reached npm.

- **Fix [product#3504](https://github.com/leadbay/product/issues/3504)** — `install --target claude-desktop` no longer silently no-ops on Claude Desktop 2026. The app moved to the DXT (Desktop Extension) system; the legacy `claude_desktop_config.json` is UI-prefs-only there and gets overwritten on every prefs save, so a block written to it disappears a few minutes later. `install` now detects DXT (via `Claude Extensions/`, `extensions-installations.json`, or `dxt:*` keys in `config.json`), prints a loud warning pointing at the `.dxt` bundle, and default-skips the legacy write. Pass `--force-legacy` to override.
- **New: shipping a `.dxt` bundle** — drag-drop into Claude Desktop 2026 → Settings → Extensions. Uploaded to each [GitHub Release](https://github.com/leadbay/mcp/releases). Dialog asks for token + region + write-toggle; no terminal required. Manifest is DXT 0.2 (`dxt_version: "0.2"`, `user_config.leadbay_token.sensitive: true`). Source for the build lives in `packages/dxt/`.
- **Fix**: `login` 401 errors no longer end with a dangling `:` when the backend returns an empty body. Messages now read `login failed (401) at <url> (wrong email or password?)`. 429/5xx get their own hints too. The core helper `formatLoginError` is exported from `@leadbay/core` so the MCP and ClawHub surfaces stay in sync.
- **README**: new section on `npm install -g` EACCES (sudo / npx / nvm workarounds — common on the official nodejs.org `.pkg`), and a pointer to the `.dxt` install for Claude Desktop 2026.
- **Also shipping** (previously merged but not yet published to npm) — `leadbay_refine_lead_targeting` / `leadbay_set_user_prompt` now send `{ user_prompt }` instead of `{ prompt }` to `POST /user_prompt` ([product#3508](https://github.com/leadbay/product/issues/3508)). Fixes the JSON deserialization 400 Ludo hit during the 0.2.2 install session.

## 0.2.3 — 2026-04-21

Bug fix release.

- **Fix [product#3508](https://github.com/leadbay/product/issues/3508)**: `leadbay_refine_lead_targeting` (and the granular `leadbay_set_user_prompt`) now send the correct `{ user_prompt }` body key to `POST /organizations/{orgId}/user_prompt`. Previous versions sent `{ prompt }`, which the backend's strict kotlinx.serialization rejected with a JSON deserialization error (400). The `dry_run` preview for both tools was printing the wrong shape too, which hid the mismatch from anyone inspecting it. New unit tests pin the wire key so this contract can't silently regress again.

## 0.2.2 — 2026-04-21

Bug fix + contract correction + mental-model docs release.

- **Fix [product#3504](https://github.com/leadbay/product/issues/3504)**: `npx -y @leadbay/mcp` no longer exits silently on Node 25. The `isEntrypoint` check now resolves both sides through `realpathSync`, so the npx shim symlink path matches the real `dist/bin.js`. Previously `main()` never ran under npx and the MCP host saw a dead connection with no diagnostic.
- Replaced stale `app.leadbay.ai` URLs in error strings (NOT_AUTHENTICATED, AUTH_INVALID, BILLING_SUSPENDED, PERMISSION_DENIED) and CLI help text with runnable commands (`leadbay-mcp install`, `leadbay-mcp login`) or "contact support". Recovery hints include `--region <us|fr>` because the CLI refuses without it (anti-cross-region credential-leak guard).
- Renamed misleading `avg_score_0_to_10` field on `pull_leads` / `bulk_qualify_leads` qualification summaries to `avg_qualification_boost`. Per-question AI agent scores are discrete boosts (-10/0/10/20), not 0-10 averages — interface JSDoc now reflects the real contract.
- `SERVER_INSTRUCTIONS` gains three new paragraphs: "How Leadbay works" (inbox + consumption-based pacing), "Two scoring layers" (basic `score` vs AI-qualified top ~10 with `ai_agent_lead_score`), and "Suggested rhythm" (daily check-in + host-agnostic scheduling hint).
- `leadbay_pull_leads`, `leadbay_research_lead`, `leadbay_bulk_qualify_leads`, `leadbay_enrich_titles`, and `leadbay_account_status` descriptions updated to reinforce the same model so the agent sees it at both top level and per-tool.
- New regression test `test/smoke/npx-entrypoint.test.ts` guards the symlink invocation path. New non-regression test asserts the inbox/pace/scoring/daily language stays in `SERVER_INSTRUCTIONS`.
- Live smoke harness extended with composite-level checks + (optional) thinking-model judge that writes a redacted report to `.context/`.

## 0.2.1 — 2026-04-21

Docs-only release.

- Removed the stale "pre-release — not yet on npm" banner that was packed into 0.2.0.
- Removed the §1.1 "install from source" section (covered the pre-publish case; obsolete now that 0.2.0 is live).
- §7 "For maintainers" replaced with a short pointer to the new `RELEASE.md` runbook.
- First release carrying signed sigstore provenance (repo is now public).

## 0.2.0 — 2026-04-20

First public npm release.

- Agent-optimized composite tool surface (pull_leads, research_lead, bulk_qualify_leads, enrich_titles, adjust_audience, refine_prompt, recall_ordered_titles, account_status, report_outreach).
- `leadbay-mcp install` one-shot setup: mints a token and registers the server with Claude Code, Claude Desktop, and Cursor.
- `leadbay-mcp login` lower-level token mint (`--write-config` drops a 0600 JSON).
- `leadbay-mcp doctor` validates token + region + quota.
- Gating: `LEADBAY_MCP_WRITE=1` for mutations, `LEADBAY_MCP_ADVANCED=1` for the granular API surface (both off by default).
- `report_outreach` requires a verification field (`gmail_message_id | calendar_event_id | user_confirmed`) to prevent pipeline poisoning.
- Mock mode via `LEADBAY_MOCK=1` for agent-author dry-running against `.context/leadbay-live-shapes/` fixtures.
- Tag-driven CI publish via `.github/workflows/release.yml` (push `mcp-v<version>` or `v<version>`).
- `--version` output now sourced from `package.json` at build time — no more drift between the tarball version and the binary's self-reported version.
