@trylighthouse/mcp-server 0.1.101 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +25 -14
  2. package/build/server.js +513 -287
  3. package/package.json +1 -1
package/build/server.js CHANGED
@@ -9,6 +9,12 @@ function text(data) {
9
9
  function err(message) {
10
10
  return { content: [{ type: "text", text: message }], isError: true };
11
11
  }
12
+ function requireId(value, alias, names) {
13
+ const picked = [value, alias].find((candidate) => typeof candidate === "string" && candidate.trim() !== "");
14
+ if (typeof picked !== "string")
15
+ throw new Error(`${names} is required`);
16
+ return picked;
17
+ }
12
18
  function profileSection(res, keys) {
13
19
  if (res == null || typeof res !== "object")
14
20
  return res;
@@ -23,11 +29,25 @@ function profileSection(res, keys) {
23
29
  return { ...envelope, data: section };
24
30
  }
25
31
  const TRANSCRIPT_WINDOW_CHARS = 40_000;
32
+ const THREAD_MESSAGES_PAGE_DEFAULT = 15;
33
+ const RETIRED_INPUTS = {
34
+ email: "email is no longer a parameter. Use emails, a comma separated list of email addresses.",
35
+ domain: "domain is no longer a parameter. Use domains, a comma separated list of domains.",
36
+ unread: "unread is no longer a parameter. Use is_read: is_read=false lists conversations that have unread messages.",
37
+ };
38
+ const ADDRESS_INPUT = z.union([
39
+ z.string(),
40
+ z.object({
41
+ name: z.string().nullable().optional(),
42
+ email: z.string(),
43
+ }),
44
+ ]);
45
+ const RECIPIENTS = z.union([ADDRESS_INPUT, z.array(ADDRESS_INPUT)]);
26
46
  export function createServer(apiKey) {
27
47
  const api = new LighthouseAPI(apiKey);
28
48
  const server = new McpServer({
29
49
  name: "lighthouse",
30
- version: "0.1.101",
50
+ version: "0.2.3",
31
51
  });
32
52
  const originalRegister = server.registerTool.bind(server);
33
53
  server.registerTool = (name, schemaOrConfig, handler) => {
@@ -44,12 +64,12 @@ export function createServer(apiKey) {
44
64
  };
45
65
  const CRM_FILTER_DESCRIPTION = "CRM filter object. Format: " +
46
66
  '{"operator":"AND","conditions":[{"field":"<key>","operator":"<op>","value":<val>}],"groups":[]}. ' +
47
- "IMPORTANT: First call lighthouse_crm_get_attributes to discover available field keys and types. " +
67
+ "IMPORTANT: First call lighthouse_crm_list_attributes to discover available field keys and types. " +
48
68
  "Operators by field type, " +
49
69
  "text/url: contains, not_contains, starts_with, ends_with, eq, neq, empty, not_empty; " +
50
70
  "number/currency/rating/percentage: gt, gte, lt, lte, eq, neq, empty, not_empty; " +
51
71
  "growth (headcount_growth on companies: the linked global profile's headcount change over 1m / 3m / 6m / 1y): gt, gte, lt, lte, eq, neq over ONE window with the value {\"timeframe\":\"1m\"|\"3m\"|\"6m\"|\"1y\",\"value\":<percent number>} (timeframe defaults to 3m; a bare number means the default window; an unknown window is refused), and empty / not_empty with no value (no window has data / at least one does); " +
52
- "duration (time_in_stage on deals, time_in_status on tasks, and every time_in_<status field> twin that lighthouse_crm_get_attributes lists with a source_field: how long the record has held its current status): gt, gte, lt, lte, eq, neq over a whole number of DAYS (the value is a bare number such as 30, never an object; anything else is refused), and empty / not_empty with no value (the status is empty / set); " +
72
+ "duration (time_in_stage on deals, time_in_status on tasks, and every time_in_<status field> twin that lighthouse_crm_list_attributes lists with a source_field: how long the record has held its current status): gt, gte, lt, lte, eq, neq over a whole number of DAYS (the value is a bare number such as 30, never an object; anything else is refused), and empty / not_empty with no value (the status is empty / set); " +
53
73
  "date/datetimez: before, after, on, empty, not_empty; " +
54
74
  "note (the notes field of companies, people, deals and custom object records: the notes linked to the record, never a body): eq (has a note matching), neq (has no note matching) with an object value whose keys are all optional and combined on ONE note: {\"title\":\"text it contains\",\"created_by\":[\"<teammate id from lighthouse_workspace_list_users>\"],\"since\":\"YYYY-MM-DD or a rolling period such as 30 days\",\"until\":\"...\",\"tags\":[\"<note tag permalink>\"],\"type\":\"standard\"|\"meeting\"} (an unknown key or a bad value is refused; with no refinement use not_empty), and empty / not_empty with no value (no note at all / at least one); " +
55
75
  "select/status/user: contains_any, not_contains_any, empty, not_empty; " +
@@ -58,7 +78,7 @@ export function createServer(apiKey) {
58
78
  "investors (a company's investors, a read-only multi_record of the GLOBAL DATABASE's companies and people): contains_any, not_contains_any, contains_all, empty, not_empty; the value is a list of investor ids from lighthouse_discovery_search_entities with record_type 'investor', never CRM record ids; " +
59
79
  "experience / education (a person's work history and schools, read-only multi_records of the GLOBAL DATABASE's companies and schools): contains_any, not_contains_any, contains_all, empty, not_empty; the value is a list of ids from lighthouse_discovery_search_entities with record_type 'company' (record_type 'school' for education), never CRM record ids; " +
60
80
  "domain/email/phone: contains_any, not_contains_any, contains_all, empty, not_empty; " +
61
- "list_entries (the record's list memberships; the value is an array of list ids from lighthouse_crm_list_lists, and the list's record_type must be this record type): contains_any, not_contains_any, contains_all, empty, not_empty (empty = in no list, not_empty = in at least one list; both take no value). A question about the records IN a list with further conditions or an order (\"companies in list X created this month, newest first\") is ONE call: either lighthouse_crm_get_list_records with the same filters and sort, or this search with a list_entries contains_any condition on that list's id beside the other conditions; never read the whole list and narrow it by hand; " +
81
+ "list_entries (the record's list memberships; the value is an array of list ids from lighthouse_crm_list_lists, and the list's record_type must be this record type): contains_any, not_contains_any, contains_all, empty, not_empty (empty = in no list, not_empty = in at least one list; both take no value). A custom field can belong to ONE list: lighthouse_crm_list_attributes leaves it out unless you pass that list's list_id, its value sits inside that list's entry under the record's list_entries rather than at the top level, and naming it in a condition or a sort answers 'List not found' when you cannot open the list. A question about the records IN a list with further conditions or an order (\"companies in list X created this month, newest first\") is ONE call: either lighthouse_crm_list_list_records with the same filters and sort, or this search with a list_entries contains_any condition on that list's id beside the other conditions; never read the whole list and narrow it by hand; " +
62
82
  "checkbox: eq (value is true or false); " +
63
83
  "interaction (first/last email & linkedin interaction, overall interaction-with): eq, neq, empty, not_empty; " +
64
84
  "interaction-type (first/last interaction type): contains_any, not_contains_any, empty, not_empty. " +
@@ -71,15 +91,15 @@ export function createServer(apiKey) {
71
91
  "interaction date fields before/after/on → ISO date or a rolling token (\"60 days\", \"60 days from now\"), empty/not_empty → omit value. " +
72
92
  "Linked-record fields filter via dotted paths <relation>.<field> (e.g. associated_people.emails on a company, associated_companies.name on a person; custom record-link fields and two-level paths like associated_deals.associated_people.emails work too) using the leaf field type's operators. Dotted paths work on company, person, deal and custom object records (a custom object's own record-link fields are its relations); on tasks and notes a dotted condition is ignored, so never use dotted fields there. " +
73
93
  "On a dotted path every operator quantifies across ALL linked records: positive operators (contains, eq, contains_any, contains_all, the date and number comparisons, not_empty) match when at least one linked record matches; negative operators (neq, not_contains, not_contains_any, empty) match when NO linked record matches, INCLUDING records with no linked records at all, so not_contains_any with every option listed returns the same records as empty. " +
74
- "semantic_search (people and companies ONLY; a FILTER, not an attribute: lighthouse_crm_get_attributes never lists it and no record carries it): operator matches, value {\"vector\":\"[...]\",\"min_similarity\"?:0 to 0.99}. It matches each record's linked global profile against a READY embedding. This API does NOT build embeddings from text: a {\"text\":...} value is refused with a 400 that says so, and you must never write a vector yourself. The vector comes from the app: a saved view's filters (lighthouse_crm_get_view returns them with the vector) or a condition the user copied with Copy for API in the filter builder. Reuse such a condition VERBATIM, at any depth of the tree and on a linked path (associated_companies.semantic_search on a person = people whose companies match the description, associated_people.semantic_search on a company). With no sort the page is ranked by similarity; only records linked to the global database can match. When the user wants a description-based search of the CRM and no saved view or copied condition carries a vector, say so and offer the attribute filters, or lighthouse_discovery_semantic_search_companies / _people for the global database (NOT the CRM). " +
94
+ "semantic_search (people and companies ONLY; a FILTER, not an attribute: lighthouse_crm_list_attributes never lists it and no record carries it): operator matches, value {\"vector\":\"[...]\",\"min_similarity\"?:0 to 0.99}. It matches each record's linked global profile against a READY embedding. This API does NOT build embeddings from text: a {\"text\":...} value is refused with a 400 that says so, and you must never write a vector yourself. The vector comes from the app: a saved view's filters (lighthouse_crm_get_view returns them with the vector) or a condition the user copied with Copy for API in the filter builder. Reuse such a condition VERBATIM, at any depth of the tree and on a linked path (associated_companies.semantic_search on a person = people whose companies match the description, associated_people.semantic_search on a company). With no sort the page is ranked by similarity; only records linked to the global database can match. When the user wants a description-based search of the CRM and no saved view or copied condition carries a vector, say so and offer the attribute filters, or lighthouse_discovery_semantic_search_companies / _people for the global database (NOT the CRM). " +
75
95
  "Dotted linked interaction filters, e.g. {\"field\":\"associated_companies.last_email_interaction\",\"operator\":\"eq\",\"value\":{\"members\":[\"<id>\"],\"directions\":[\"outbound\"]}}; dotted interaction keys support eq, neq, empty and not_empty. " +
76
96
  "The `operator` of a block (the top level OR any group) is \"AND\" or \"OR\" and joins that block's conditions AND its nested groups: \"AND\" = every one must match, \"OR\" = at least one matches. Set the top-level operator to \"OR\" to OR the top-level conditions, or nest a group to mix logic like (A OR B) AND C. " +
77
97
  'Nested-group example for "(domain contains acme OR name contains acme) AND created after 2025-01-01": ' +
78
98
  '{"operator":"AND","conditions":[{"field":"created_at","operator":"after","value":"2025-01-01"}],"groups":[{"operator":"OR","conditions":[{"field":"domain","operator":"contains_any","value":["acme"]},{"field":"name","operator":"contains","value":"acme"}],"groups":[]}]} ' +
79
- "(field keys above are illustrative, use the real keys from lighthouse_crm_get_attributes). " +
99
+ "(field keys above are illustrative, use the real keys from lighthouse_crm_list_attributes). " +
80
100
  "Dates have NO range operator, express a window as two conditions (after <start> AND before <end>) in an AND group, computing the bounds from today's date. " +
81
- "Use the EXACT field keys and option values returned by lighthouse_crm_get_attributes / lighthouse_crm_list_options, never abbreviate or invent them, the chosen values for a select/multi_select condition MUST be a subset of that field's options. If a requested concept has no matching field or option (e.g. a 'competitor'/'portfolio' status that no field actually holds), TELL THE USER rather than dropping the clause or mapping it onto an unrelated field. " +
82
- "Do NOT use discovery field keys here, CRM fields come from lighthouse_crm_get_attributes. " +
101
+ "Use the EXACT field keys and option values returned by lighthouse_crm_list_attributes / lighthouse_crm_list_options, never abbreviate or invent them, the chosen values for a select/multi_select condition MUST be a subset of that field's options. If a requested concept has no matching field or option (e.g. a 'competitor'/'portfolio' status that no field actually holds), TELL THE USER rather than dropping the clause or mapping it onto an unrelated field. " +
102
+ "Do NOT use discovery field keys here, CRM fields come from lighthouse_crm_list_attributes. " +
83
103
  "RESPONSE VALIDATION META: this search NEVER fails on a bad filter; instead the response carries, alongside `records`/`total`, three arrays you MUST inspect (collected recursively across nested groups): " +
84
104
  "`ignored_filter_fields` (field keys not recognized; that condition was dropped), " +
85
105
  "`invalid_operators` ([{field, operator}] where the operator is not valid for that field's type; that condition was dropped), and " +
@@ -87,7 +107,7 @@ export function createServer(apiKey) {
87
107
  "If ANY are non-empty, part of your filter did NOT apply (results are broader/narrower than intended): fix the offending field/operator and retry, or tell the user which clauses could not be applied. A `warnings` array summarizes the same in plain text.";
88
108
  const CRM_SORT_DESCRIPTION = 'CRM sort array. Format: [{"field":"<key>","direction":"asc"|"desc"}]. ' +
89
109
  "Multiple entries sort by the first, then tie-break by the next. " +
90
- "NOT every field is sortable. A field is sortable ONLY when its type (the `data_type` returned by lighthouse_crm_get_attributes) is one of: " +
110
+ "NOT every field is sortable. A field is sortable ONLY when its type (the `data_type` returned by lighthouse_crm_list_attributes) is one of: " +
91
111
  "text, url, number, currency, percentage, rating, date, datetime, datetimez, select, status, user, checkbox, " +
92
112
  "record, record_company_name, record_person_name, record_deal_name, growth, note, duration. " +
93
113
  "growth (headcount_growth) sorts by ONE window: add \"timeframe\":\"1m\"|\"3m\"|\"6m\"|\"1y\" to the sort item ({\"field\":\"headcount_growth\",\"direction\":\"desc\",\"timeframe\":\"6m\"}; 3m when omitted; an unknown window comes back in invalid_sort_fields). " +
@@ -95,15 +115,15 @@ export function createServer(apiKey) {
95
115
  "duration (time_in_stage, time_in_status, every time_in_<status field> twin) orders by the time in the current status: asc = fewest days first, records whose status is empty last; the sort item is the plain {\"field\",\"direction\"} with no extra key. " +
96
116
  "NOT sortable (never put these in sort): multi-value fields (multi_select, signal_select, category, multi_user, multi_record), domain, email, phone, list_entries, " +
97
117
  "linked/lookup fields, and interaction object & interaction-type fields (the first/last email & linkedin interaction objects and the interaction-type selects); sort by the interaction's date sub-field instead. " +
98
- "Use the EXACT field key and type from lighthouse_crm_get_attributes; if the user asks to sort by a field whose type is not in the sortable list above, tell them that field can't be sorted rather than guessing a key. " +
118
+ "Use the EXACT field key and type from lighthouse_crm_list_attributes; if the user asks to sort by a field whose type is not in the sortable list above, tell them that field can't be sorted rather than guessing a key. " +
99
119
  "RESPONSE VALIDATION META: an unsortable field does NOT fail the search; it comes back in the response's `invalid_sort_fields` array ([{field, reason}]) and the search runs in the default order. If `invalid_sort_fields` is non-empty your sort did not apply: pick a sortable field or tell the user.";
100
120
  const DISCOVERY_FILTER_DESCRIPTION = "Discovery filter object. Format: " +
101
121
  '{"operator":"AND","conditions":[{"field":"<permalink>","operator":"<op>","value":<val>}],"groups":[]}. ' +
102
122
  "IMPORTANT: `field` MUST be one of the prefixed discovery filter keys listed for this entity at the end of this description (company_* or person_*). " +
103
- "Those keys are NOT the data fields returned by lighthouse_discovery_get_attributes: a condition whose `field` is a raw data column (e.g. 'categories', 'headquarter_country', 'founded_on') is dropped (it filters nothing) and reported back in the response's `ignored_filter_fields` so you can catch it. " +
104
- "For category/industry values, call lighthouse_discovery_get_industries. " +
105
- "For location values, call lighthouse_discovery_get_locations and use its `value` field: countries are ISO 3166-1 alpha-2 codes in UPPERCASE (e.g. \"US\", \"GB\", \"DE\"), regions are the region codes it returns. Never pass a lowercase code (\"us\") or a country name (\"United States\"). This applies to EVERY country/region filter key (company_hq_country, company_investor_country, person_country, person_work_country, and the rest). " +
106
- "For tag/highlight values, call lighthouse_discovery_get_tags. " +
123
+ "Those keys are NOT the data fields returned by lighthouse_discovery_list_attributes: a condition whose `field` is a raw data column (e.g. 'categories', 'headquarter_country', 'founded_on') is dropped (it filters nothing) and reported back in the response's `ignored_filter_fields` so you can catch it. " +
124
+ "For category/industry values, call lighthouse_discovery_list_industries. " +
125
+ "For location values, call lighthouse_discovery_list_locations and use its `value` field: countries are ISO 3166-1 alpha-2 codes in UPPERCASE (e.g. \"US\", \"GB\", \"DE\"), regions are the region codes it returns. Never pass a lowercase code (\"us\") or a country name (\"United States\"). This applies to EVERY country/region filter key (company_hq_country, company_investor_country, person_country, person_work_country, and the rest). " +
126
+ "For tag/highlight values, call lighthouse_discovery_list_tags. " +
107
127
  "Fields use prefixed keys (company_name, company_headcount, person_country, etc.). " +
108
128
  "Operators by field type, " +
109
129
  "short_text: contains, is; " +
@@ -126,8 +146,8 @@ export function createServer(apiKey) {
126
146
  "json: contains, not_contains, empty, not_empty. " +
127
147
  "SPECIAL COMPOUND TYPES, " +
128
148
  "signal_select (company_signals): operators contains_any/not_contains_any/contains_all, value is array of signal strings e.g. [\"new_funding_round\",\"growth_headcount\"]; " +
129
- "signal_select (person_signals): operators contains_any/not_contains_any/contains_all, value is {\"signals\":[\"company_registration\",...],\"registration_countries\":[\"US\",\"DE\",...],\"signal_dates\":{\"stealth\":\"2024-03-01\"},\"stealth_predicted_areas\":[\"fintech\",...]} " +
130
- "(every key besides signals is optional: registration_countries only filters the company_registration signal by country; " +
149
+ "signal_select (person_signals): operators contains_any/not_contains_any/contains_all, value is {\"signals\":[\"company_registration\",...],\"registration_countries\":[\"DE\",\"GB\",...],\"signal_dates\":{\"stealth\":\"2024-03-01\"},\"stealth_predicted_areas\":[\"fintech\",...]} " +
150
+ "(every key besides signals is optional: registration_countries only filters the company_registration signal by country, as UPPERCASE ISO codes, and the country is where the company was registered, and registrations exist only for the countries whose company registers we read (AT, BE, CH, CZ, DE, DK, EE, ES, FI, FR, GB, GR, HR, LU, PL, PT, SE, SK), so any other country returns no people; " +
131
151
  "signal_dates maps a signal to a 'started on/after' ISO date and applies to stealth / out_of_stealth / founded_new_company; " +
132
152
  "stealth_predicted_areas refines the stealth signal by predicted category group, same values as person_stealth_predicted_area); " +
133
153
  "language_select: operators contains_any/not_contains_any/contains_all, value is {\"languages\":[<ISO 639-1 two-letter codes, e.g. \"en\",\"de\",\"fr\">],\"proficiency\":[<one or more of exactly \"NATIVE_BILINGUAL\",\"EXPERT\",\"ADVANCED\",\"INTERMEDIATE\",\"BASIC\">]} " +
@@ -137,7 +157,7 @@ export function createServer(apiKey) {
137
157
  "date ops → ISO date string \"YYYY-MM-DD\". " +
138
158
  "The `operator` of a block (the top level OR any group) is \"AND\" or \"OR\" and joins that block's conditions AND its nested groups: \"AND\" = every one must match, \"OR\" = at least one matches. Set the top-level operator to \"OR\" to OR the top-level conditions, or nest a group to mix logic, e.g. (A OR B) AND C = {\"operator\":\"AND\",\"conditions\":[C],\"groups\":[{\"operator\":\"OR\",\"conditions\":[A,B],\"groups\":[]}]}. A worked example with real keys is in the field-key list below. " +
139
159
  "company_signals value is a plain array of strings; person_signals value is an object {\"signals\":[...],\"registration_countries\"?:[...],\"signal_dates\"?:{...},\"stealth_predicted_areas\"?:[...]}, these are the only contains_any fields whose value is not a bare array. " +
140
- "Use the company_*/person_* keys listed below VERBATIM, do not invent variants (no _usd suffix, no abbreviation like hq for headquarter) and do not use the bare permalinks returned by lighthouse_discovery_get_attributes; any unrecognized key is dropped (the search runs as if that condition were absent) and echoed in the response's `ignored_filter_fields`, so ALWAYS check that array, otherwise a mistyped key returns a result that looks fine but is unfiltered. " +
160
+ "Use the company_*/person_* keys listed below VERBATIM, do not invent variants (no _usd suffix, no abbreviation like hq for headquarter) and do not use the bare permalinks returned by lighthouse_discovery_list_attributes; any unrecognized key is dropped (the search runs as if that condition were absent) and echoed in the response's `ignored_filter_fields`, so ALWAYS check that array, otherwise a mistyped key returns a result that looks fine but is unfiltered. " +
141
161
  "NOT EXPRESSIBLE on this surface (it searches the global database, NOT your CRM): CRM list membership, CRM record owner / unassigned, CRM custom fields, deal stage, role-change dates, and geography finer than country (no US state). If a request needs one of these, apply what you CAN and TELL THE USER which clauses you could not express, do NOT silently drop them or map them onto an unrelated field. " +
142
162
  "Do NOT use CRM attribute keys here. The complete set of valid discovery filter keys for this entity is listed at the end of this description. " +
143
163
  "RESPONSE VALIDATION META: this search NEVER fails on a bad filter; instead the response carries, alongside `items`/`total`, three arrays you MUST inspect (collected recursively across nested groups): " +
@@ -151,57 +171,64 @@ export function createServer(apiKey) {
151
171
  "No other field is sortable here (NOT headcount, name, last-funding, etc.); an unsupported `by` is ignored and results fall back to relevance order, with a note in meta.warnings. " +
152
172
  "If the user asks to sort companies by anything else, tell them only founding date and total funding are supported. " +
153
173
  "RESPONSE VALIDATION META: an unsupported sort does NOT fail the search; it comes back in the response's `invalid_sort_fields` array (and `warnings`) and results fall back to relevance order.";
174
+ const DISCOVERY_CRM_ID_NOTE = " EVERY result row carries crm_id: the id of the CRM record this workspace already has for that company or person, and null when it has none. Read it instead of searching the CRM once per row: \"companies like this one that we are NOT tracking yet\" is a filter on this key. When it is set, lighthouse_crm_get_record (record_type 'company' or 'person') opens that record and lighthouse_crm_update_record edits it; when it is null, lighthouse_crm_upsert_record is what brings the entity into the CRM without making a duplicate.";
154
175
  const COMPANY_DISCOVERY_FIELDS = " COMPANY FILTER KEYS (use EXACTLY these as the condition `field`; any other key is ignored): " +
155
176
  "Basics: company_name (short_text), company_domain (url), company_description (long_text), company_headcount (number), company_founded_on (date, year), company_created_at (date). " +
156
- "Industry: company_categories (multi_select; values from lighthouse_discovery_get_industries → categories), company_sdg_impact (multi_select; values from get_industries → sdg_categories). " +
177
+ "Industry: company_categories (multi_select; values from lighthouse_discovery_list_industries → categories), company_sdg_impact (multi_select; values from list_industries → sdg_categories). " +
157
178
  "Signals: company_signals (multi_select; values: new_funding_round, growth_website, growth_headcount, growth_job_posts, growth_lnk_followers, growth_tw_followers, growth_npm, growth_app, growth_patents, bl_launch, hn_launch, gh_launch, prhu_launch, prhu_award). " +
158
179
  "Tags: company_tags (multi_select; values: 2nd_time_founder, serial_founder, has_technical_founder, has_non_technical_founder, raised_previous_funding, has_previous_exit, sales_exp, co_exp, eng_exp, ai_and_data_exp, product_exp, hr_exp, impact_people_tag, a_founder_left, all_founders_left). " +
159
- "Funding: company_total_funding (number, USD), company_last_funding_amount (number, USD), company_last_funding_date (date), company_last_funding_type (funding_type_multi_select; values from lighthouse_discovery_get_funding_types, e.g. seed, series_a, plus no_funding to match companies with no recorded funding round). " +
160
- "Location: company_hq_country (multi_select; UPPERCASE ISO alpha-2 codes e.g. \"US\", \"GB\" from lighthouse_discovery_get_locations → countries), company_hq_region (multi_select; get_locations → regions), company_hq_city (text), company_german_state (multi_select; German federal states). " +
180
+ "Funding: company_total_funding (number, USD), company_last_funding_amount (number, USD), company_last_funding_date (date), company_last_funding_type (funding_type_multi_select; lighthouse_discovery_list_funding_types `value`, e.g. seed, series_a, plus no_funding to match companies with no recorded funding round). " +
181
+ "Location: company_hq_country (multi_select; UPPERCASE ISO alpha-2 codes e.g. \"US\", \"GB\" from lighthouse_discovery_list_locations → countries), company_hq_region (multi_select; list_locations → regions), company_hq_city (text), company_german_state (multi_select; German federal states). " +
161
182
  "Socials: company_linkedin, company_twitter, company_facebook, company_github, company_producthunt (url; matches the profile handle/permalink with text operators). " +
162
- "Investors (who invested IN this company): company_investor (investor_multi_select; ids from lighthouse_discovery_search_entities record_type 'investor'), company_investor_country (multi_select; get_locations countries), company_investor_region (multi_select; get_locations regions). " +
163
- "Investments (the company AS an investor; the investments_top_* keys match the investor's TOP-RANKED preference only, e.g. their #1 country, not every investment they ever made): company_is_investor (checkbox; eq true = the company is itself an investor such as a fund or CVC, eq false = exclude investors), company_num_investments (number; total recorded investments), company_investments_top_category (multi_select; get_industries categories), company_investments_top_sdg (multi_select; get_industries sdg_categories), company_investments_top_country (multi_select; get_locations countries), company_investments_top_region (multi_select; get_locations regions), company_investments_top_stage (funding_type_multi_select; get_funding_types values, no_funding not applicable). " +
164
- "Team: company_team_past_companies (work_experience_multi_select; ids from lighthouse_discovery_search_entities record_type 'company'), company_team_education (education_multi_select; ids from lighthouse_discovery_search_entities record_type 'school'), company_team_experience (multi_select; same values as company_tags), company_team_language (language_select), company_team_language_proficiency (multi_select; use with company_team_language to require a proficiency level), company_team_name_origin (multi_select; get_locations countries), company_team_location_country, company_team_location_region, company_team_work_country, company_team_work_region, company_team_edu_country, company_team_edu_region (multi_select; get_locations). " +
183
+ "Investors (who invested IN this company): company_investor (investor_multi_select; ids from lighthouse_discovery_search_entities record_type 'investor'), company_investor_country (multi_select; list_locations countries), company_investor_region (multi_select; list_locations regions). " +
184
+ "Investments (the company AS an investor; the investments_top_* keys match the investor's TOP-RANKED preference only, e.g. their #1 country, not every investment they ever made): company_is_investor (checkbox; eq true = the company is itself an investor such as a fund or CVC, eq false = exclude investors), company_num_investments (number; total recorded investments), company_investments_top_category (multi_select; list_industries categories), company_investments_top_sdg (multi_select; list_industries sdg_categories), company_investments_top_country (multi_select; list_locations countries), company_investments_top_region (multi_select; list_locations regions), company_investments_top_stage (funding_type_multi_select; lighthouse_discovery_list_funding_types `value`, no_funding not applicable). " +
185
+ "Team: company_team_past_companies (work_experience_multi_select; ids from lighthouse_discovery_search_entities record_type 'company'), company_team_education (education_multi_select; ids from lighthouse_discovery_search_entities record_type 'school'), company_team_experience (multi_select; same values as company_tags), company_team_language (language_select), company_team_language_proficiency (multi_select; use with company_team_language to require a proficiency level), company_team_name_origin (multi_select; list_locations countries), company_team_location_country, company_team_location_region, company_team_work_country, company_team_work_region, company_team_edu_country, company_team_edu_region (multi_select; list_locations). " +
165
186
  "CRM (filter the global database by YOUR workspace CRM): company_internal_crm_status (checkbox; eq true = already in your CRM, false = not), company_crm_list_entries (multi_select; companies that are in specific CRM company lists, list ids from lighthouse_crm_list_lists), company_crm_deals_list_entries (multi_select; companies whose deals are in specific deal lists), company_crm_team_network (multi_user; companies whose team members are connected to specific workspace users, ids from lighthouse_workspace_list_users), company_crm_team_list_entries (multi_select; companies whose team members are in specific people lists), company_external_crm_status (checkbox; needs a connected external CRM), company_external_crm_lists (multi_select; needs a connected external CRM). " +
166
187
  'NESTED-GROUP EXAMPLE for "headcount 10-50 AND (HQ in US or UK) AND raised Series A": ' +
167
188
  '{"operator":"AND","conditions":[{"field":"company_headcount","operator":"between","value":{"min":10,"max":50}},{"field":"company_last_funding_type","operator":"contains_any","value":["series_a"]}],"groups":[{"operator":"OR","conditions":[{"field":"company_hq_country","operator":"contains_any","value":["US","GB"]}],"groups":[]}]} ' +
168
- "(country codes from get_locations, funding-type values from get_funding_types).";
189
+ "(country codes from list_locations, funding-type values from lighthouse_discovery_list_funding_types).";
169
190
  const PERSON_DISCOVERY_FIELDS = " PERSON FILTER KEYS (use EXACTLY these as the condition `field`; any other key is ignored): " +
170
191
  "Basics: person_name (short_text), person_created_at (date). " +
171
- "Investments (the person AS an investor, e.g. an angel; the investments_top_* keys match the investor's TOP-RANKED preference only, e.g. their #1 country, not every investment they ever made): person_is_investor (checkbox; eq true = only investors, eq false = exclude investors), person_num_investments (number; total recorded investments), person_investments_top_category (multi_select; get_industries categories), person_investments_top_sdg (multi_select; get_industries sdg_categories), person_investments_top_country (multi_select; get_locations countries), person_investments_top_region (multi_select; get_locations regions), person_investments_top_stage (funding_type_multi_select; get_funding_types values, no_funding not applicable). " +
172
- "Industry: person_current_categories, person_past_categories (multi_select; values from lighthouse_discovery_get_industries → categories), person_current_sdg_impact, person_past_sdg_impact (multi_select; get_industries → sdg_categories). " +
173
- "Signals: person_signals (signal_select; value {\"signals\":[...],\"registration_countries\"?:[...],\"signal_dates\"?:{<signal>:\"YYYY-MM-DD\"},\"stealth_predicted_areas\"?:[...]}), person_registration_country (multi_select; filters company_registration signals by country, get_locations countries). " +
192
+ "Investments (the person AS an investor, e.g. an angel; the investments_top_* keys match the investor's TOP-RANKED preference only, e.g. their #1 country, not every investment they ever made): person_is_investor (checkbox; eq true = only investors, eq false = exclude investors), person_num_investments (number; total recorded investments), person_investments_top_category (multi_select; list_industries categories), person_investments_top_sdg (multi_select; list_industries sdg_categories), person_investments_top_country (multi_select; list_locations countries), person_investments_top_region (multi_select; list_locations regions), person_investments_top_stage (funding_type_multi_select; lighthouse_discovery_list_funding_types `value`, no_funding not applicable). " +
193
+ "Industry: person_current_categories, person_past_categories (multi_select; values from lighthouse_discovery_list_industries → categories), person_current_sdg_impact, person_past_sdg_impact (multi_select; list_industries → sdg_categories). " +
194
+ "Signals: person_signals (signal_select; value {\"signals\":[...],\"registration_countries\"?:[...],\"signal_dates\"?:{<signal>:\"YYYY-MM-DD\"},\"stealth_predicted_areas\"?:[...]}), person_registration_country (multi_select; filters company_registration signals by country, list_locations countries; the country is where the company was registered, and registrations exist only for the countries whose company registers we read (AT, BE, CH, CZ, DE, DK, EE, ES, FI, FR, GB, GR, HR, LU, PL, PT, SE, SK), so any other country returns no people). " +
174
195
  "Tags: person_tags (multi_select; values: phd_doctorate, 2nd_time_founder, serial_founder, has_exit, raised_funding, sales_exp, co_exp, eng_exp, ai_and_data_exp, product_exp, hr_exp). " +
175
- "Language & origin: person_language (language_select), person_language_proficiency (multi_select; use with person_language to require a proficiency level), person_name_origin (multi_select; get_locations countries). " +
176
- "Current location: person_country (multi_select; UPPERCASE ISO alpha-2 codes e.g. \"US\", \"GB\" from lighthouse_discovery_get_locations → countries), person_region (multi_select; get_locations → regions), person_german_state (multi_select; German federal states). " +
177
- "Experience: person_past_companies (work_experience_multi_select; ids from lighthouse_discovery_search_entities record_type 'company'), person_education (education_multi_select; ids from lighthouse_discovery_search_entities record_type 'school'), person_experience_start_date, person_experience_end_date (date; matches people with at least one work experience whose start/end date passes the bound, independent of the other experience conditions), person_work_country, person_work_region, person_edu_country, person_edu_region (multi_select; get_locations). " +
196
+ "Language & origin: person_language (language_select), person_language_proficiency (multi_select; use with person_language to require a proficiency level), person_name_origin (multi_select; list_locations countries). " +
197
+ "Current location: person_country (multi_select; UPPERCASE ISO alpha-2 codes e.g. \"US\", \"GB\" from lighthouse_discovery_list_locations → countries), person_region (multi_select; list_locations → regions), person_german_state (multi_select; German federal states). " +
198
+ "Experience: person_past_companies (work_experience_multi_select; ids from lighthouse_discovery_search_entities record_type 'company'), person_education (education_multi_select; ids from lighthouse_discovery_search_entities record_type 'school'), person_experience_start_date, person_experience_end_date (date; matches people with at least one work experience whose start/end date passes the bound, independent of the other experience conditions), person_work_country, person_work_region, person_edu_country, person_edu_region (multi_select; list_locations). " +
178
199
  "Stealth: person_stealth_start_date, person_stealth_end_date (date), person_stealth_predicted_area (multi_select; the predicted category-GROUP a stealth founder is building in. Values: agriculture_farming, ai_ml, apparel_fashion, ar_vr, cloud_devops, commerce, consumer_goods_electronics, content_publishing, crypto_blockchain, customer_service, data_analytics, deeptech, design, edtech, energy, engineering_applied_sciences, entertainment_recreation, events, financial_services_fintech, food_beverage, gaming, geospatial_technologies, hardware, healthtech_biotech, hospitality_travel, hr, impact, industrial_manufacturing, infrastructure, it_software, legal, miscellaneous, natural_resources, public_sector_defense, real_estate, sales_marketing, security_privacy_risk, sharing_economy, social_communities, sports, telecommunications, transportation_mobility_logistics). " +
179
- "Primary company: person_company_hq_country, person_company_hq_region (multi_select; get_locations), person_company_headcount (number), person_company_founded_on (date), person_company_total_funding, person_company_last_funding_amount (number, USD), person_company_last_funding_date (date), person_company_last_funding_type (funding_type_multi_select; values from lighthouse_discovery_get_funding_types, including no_funding). " +
200
+ "Primary company: person_company_hq_country, person_company_hq_region (multi_select; list_locations), person_company_headcount (number), person_company_founded_on (date), person_company_total_funding, person_company_last_funding_amount (number, USD), person_company_last_funding_date (date), person_company_last_funding_type (funding_type_multi_select; lighthouse_discovery_list_funding_types `value`, including no_funding). " +
180
201
  "CRM: person_internal_crm_status (checkbox; eq true/false), person_crm_network (multi_user; ids from lighthouse_workspace_list_users), person_external_crm_status (checkbox; needs a connected external CRM), person_external_crm_lists (multi_select; needs a connected external CRM). " +
181
202
  'NESTED-GROUP EXAMPLE for "(based in Germany or France) AND a technical or AI/data background": ' +
182
203
  '{"operator":"AND","conditions":[],"groups":[{"operator":"OR","conditions":[{"field":"person_country","operator":"contains_any","value":["DE","FR"]}],"groups":[]},{"operator":"OR","conditions":[{"field":"person_tags","operator":"contains_any","value":["eng_exp","ai_and_data_exp"]}],"groups":[]}]} ' +
183
- "(an outer AND of two OR groups; country codes from get_locations).";
184
- server.registerTool("lighthouse_crm_get_attributes", {
185
- title: "Get CRM Attributes",
204
+ "(an outer AND of two OR groups; country codes from list_locations).";
205
+ server.registerTool("lighthouse_crm_list_attributes", {
206
+ title: "List CRM Attributes",
186
207
  description: "Get a lightweight catalog of the fields for a CRM record type: each field's permalink, label, data_type, links_to, read_only, is_custom, has_options, and source_field. " +
187
208
  "`source_field` names the status attribute a `duration` attribute is derived from (null on every other attribute); such attributes (time_in_stage on deals, time_in_status on tasks, time_in_<status> beside a custom status field) are read-only and computed, never written. " +
188
209
  "ALWAYS call this before building filters for search_records / list_records or creating/updating views. Use the exact permalinks it returns. " +
189
210
  "To keep the response small it does NOT include select/multi_select option values: when a field has has_options=true, call lighthouse_crm_list_options with that permalink to get its allowed values before filtering or setting it. " +
190
211
  "Supports built-in types (company, person, deal, task) and custom objects (use the object's permalink, e.g. 'lp', 'project'). " +
191
- "When called without record_type, returns all types including custom objects as top-level keys.",
212
+ "When called without record_type, returns all types including custom objects as top-level keys. " +
213
+ "A custom field that belongs to a single list is left out unless you pass that list's list_id, and a list you cannot open answers 'List not found'. Filtering or sorting on such a field without access to its list answers the same way.",
192
214
  inputSchema: {
193
215
  record_type: z
194
216
  .string()
195
217
  .optional()
196
218
  .describe("Record type: 'company', 'person', 'deal', 'task', or a custom object permalink. Omit to get all types."),
219
+ list_id: z
220
+ .string()
221
+ .optional()
222
+ .describe("List ID (UUID) to scope attributes. Returns global fields plus the custom fields scoped to that list. If provided without record_type, the list's record type is resolved for you. A list you cannot open answers like one that does not exist."),
197
223
  list: z
198
224
  .string()
199
225
  .optional()
200
- .describe("List ID (UUID) to scope attributes. Returns global fields plus list-scoped custom fields. If provided without record_type, auto-resolves the list's entity type."),
226
+ .describe("Legacy name for list_id. Kept forever; list_id wins when both are sent."),
201
227
  },
202
228
  }, async (params) => {
203
229
  try {
204
- const res = await api.post("/mcp/records/attributes", params);
230
+ const { list_id, list, ...rest } = params;
231
+ const res = await api.post("/mcp/records/attributes", { ...rest, list: list_id ?? list });
205
232
  return text(res);
206
233
  }
207
234
  catch (e) {
@@ -312,8 +339,8 @@ export function createServer(apiKey) {
312
339
  return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
313
340
  }
314
341
  });
315
- server.registerTool("lighthouse_crm_get_color_palette", {
316
- title: "Get Color Palette",
342
+ server.registerTool("lighthouse_crm_list_colors", {
343
+ title: "List Colors",
317
344
  description: "Get the available color palette for select/multi_select/status field options. " +
318
345
  "Returns an array of {hex_code, label} objects. " +
319
346
  "Use this to discover valid hex color codes when creating or updating select options.",
@@ -329,14 +356,15 @@ export function createServer(apiKey) {
329
356
  });
330
357
  server.registerTool("lighthouse_crm_list_options", {
331
358
  title: "List CRM Field Options",
332
- description: "Get the allowed values (value, label, color) for a CRM select / multi_select field. " +
333
- "Call this when lighthouse_crm_get_attributes shows has_options=true for a field and you need its values before filtering or setting it. " +
359
+ description: "Get the allowed values for a CRM select / multi_select field. Each row is { value, label, color, priority, is_default, enabled }, the same object the options[] of an attribute carries; a built-in value the workspace switched off is listed with enabled: false rather than left out, so do not offer it when setting a field. " +
360
+ "Call this when lighthouse_crm_list_attributes shows has_options=true for a field and you need its values before filtering or setting it. " +
334
361
  "Results are paginated (max 100 per page): use `search` to narrow by label, and `limit` / `offset` to page through a field with many options. " +
335
362
  "For custom objects, use the object's permalink as record_type.",
336
363
  inputSchema: {
337
364
  record_type: z.string().describe("Record type: 'company', 'person', 'deal', 'task', or a custom object permalink"),
338
365
  permalink: z.string().describe("Field permalink of the select/multi_select field"),
339
366
  search: z.string().optional().describe("Filter options whose label contains this text (case-insensitive)"),
367
+ query: z.string().optional().describe("Same as search. Either name works; search wins when both are sent."),
340
368
  limit: z.number().optional().describe("Max options to return (default 100, max 100)"),
341
369
  offset: z.number().optional().describe("Number of options to skip for pagination (default 0)"),
342
370
  },
@@ -353,7 +381,7 @@ export function createServer(apiKey) {
353
381
  title: "Create CRM Field Option",
354
382
  description: "Add a new option to a select/multi_select field. The option value is auto-generated from the label. " +
355
383
  "Re-creating a system default option that was previously removed restores it (its original color and label are kept). " +
356
- "Returns the updated list of all options for the field (each with value, label, color). " +
384
+ "Returns the updated list of ALL options for the field, each row { value, label, color, priority, is_default, enabled }, with a switched-off built-in value listed as enabled: false, plus meta.total. The list comes back in the order the field offers its options, so do not read the first row as the option you just created: find it by the label you sent. " +
357
385
  "For custom objects, use the object's permalink as record_type. " +
358
386
  "Options can only be created on company, person, deal, and custom objects.",
359
387
  inputSchema: {
@@ -395,9 +423,10 @@ export function createServer(apiKey) {
395
423
  description: "Search and filter records in YOUR CRM workspace (companies, people, or deals you've added). " +
396
424
  "Supports filters, sorting, pagination, and saved views. " +
397
425
  "A search by MEANING over people or company records is a semantic_search condition in filters; it needs the ready vector of a saved view (lighthouse_crm_get_view) or of a condition copied from the app, never text and never a vector you wrote (the filters description has the rule). " +
398
- "NEVER guess field keys, ALWAYS call lighthouse_crm_get_attributes first to get the exact field keys " +
426
+ "NEVER guess field keys, ALWAYS call lighthouse_crm_list_attributes first to get the exact field keys " +
399
427
  "for the record_type, then use those keys in your filter conditions. " +
400
428
  "The response's `meta` includes `total` (an integer match count) and `ignored_filter_fields` (any filter key that is not a valid attribute for this record_type, each with a did_you_mean suggestion when a close match can be computed). Check `ignored_filter_fields` to confirm every filter was actually applied. " +
429
+ "A list_entries condition naming a list you cannot open answers 'List not found', and so does a condition or a sort on a custom field that belongs to such a list: the field is the membership. " +
401
430
  "NOT for discovering new companies/people, use lighthouse_discovery_* tools for that.",
402
431
  inputSchema: {
403
432
  record_type: z.string().describe("Record type (company, person, deal, or custom object permalink)"),
@@ -442,8 +471,8 @@ export function createServer(apiKey) {
442
471
  "Filter these fields by the signal VALUES (strings). Search results carry the same shape. " +
443
472
  "Two more read-only keys on a company: investors ({ company: [{ database_id, crm_id, name, avatar_url, is_lead }], person: [{ database_id, crm_id, first_name, last_name, avatar_url, is_lead }] }: the global database's investors of the company, both keys always present; database_id = the investor's id in the database tools and the value the investors filter takes, crm_id = the workspace's CRM record linked to that investor or null when it is not in the CRM, is_lead = led one of its rounds) and headcount_growth ({ \"1m\", \"3m\", \"6m\", \"1y\" }: the headcount's percent change per window, null when unknown, plus value = the headcount on measured_on; null when the record has no linked profile or no data). " +
444
473
  "Two read-only keys on a person: experience and education ([{ database_id, crm_id, name, avatar_url }]: the companies the linked global profile worked at and the schools it studied at, always present, [] without a linked profile; database_id = the id in the database tools and the value the experience / education filters take, crm_id = the workspace's CRM company linked to that company or school, null when it is not in the CRM). " +
445
- "A third read-only key on every company, person, deal and custom object record: notes ({ count, last_note_at, notes: [ { id, title, type, created_at, created_by, tags, records, meeting } ] }: the notes linked to the record, the newest five with the author, the kind, the tags, the other records they link to and the meeting details of a meeting note; never the body, which lighthouse_crm_get_note reads; count is every note the caller may see). " +
446
- "A read-only duration twin sits beside every status field (time_in_stage on a deal, time_in_status on a task, time_in_<status field> beside a custom status field; lighthouse_crm_get_attributes lists each with its source_field): { days, since } (the whole days the record has held its current status and the ISO timestamp since when), null while the status is empty. " +
474
+ "A third read-only key on every company, person, deal and custom object record: notes ({ count, last_note_at, notes: [ { id, title, type, created_at, created_by, tags, records, meeting } ] }: the notes linked to the record, the newest five with the author, the kind, the tags, the records they link to (the record itself included) and the meeting details of a meeting note; never the body, which lighthouse_crm_get_note reads; count is every note the caller may see). " +
475
+ "A read-only duration twin sits beside every status field (time_in_stage on a deal, time_in_status on a task, time_in_<status field> beside a custom status field; lighthouse_crm_list_attributes lists each with its source_field): { days, since } (the whole days the record has held its current status and the ISO timestamp since when), null while the status is empty. " +
447
476
  "(Discovery profiles keep their own signals list: one row per feed window with type, created_at, timeframe, percentage_change.)",
448
477
  inputSchema: {
449
478
  record_type: z.string().describe("Record type (company, person, deal, or custom object permalink)"),
@@ -458,8 +487,8 @@ export function createServer(apiKey) {
458
487
  return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
459
488
  }
460
489
  });
461
- server.registerTool("lighthouse_crm_get_record_activity", {
462
- title: "Get Record Activity",
490
+ server.registerTool("lighthouse_crm_list_record_activity", {
491
+ title: "List Record Activity",
463
492
  description: "Read the activity feed of one CRM record, newest first: who changed what, when, and from where. Use it to answer questions about history (\"what changed on this deal last week\", \"who moved it to won\", \"which fields has nobody touched\") instead of guessing from the record's current values. " +
464
493
  "Each entry has: action (created / updated / deleted / merged), occurred_at, summary (a ready-made sentence, ending \"via <the source>\" when a workflow or an API key made the change rather than a person, since both log a human as the actor), target ({ record_type, id, name }; the feed also carries the notes and tasks attached to the record, so the target is not always the record itself), actor (null for automated changes), and source. " +
465
494
  "source always has { type, label }: type is app / mobile / api / mcp / workflow / ai_assistant / notetaker / email_sync / calendar_sync / linkedin_sync / inbound_email / inbound_whatsapp / lp_portal / sync, and label names the exact origin. The four trace keys are always present and null when the origin did not record them: workflow is { id, name, run_id, node_id } when a workflow made the change (tracing an entry back to the exact run and step), api_key_id / mcp_tool identify the key or AI tool behind an API change, and trigger is automatic or manual where the origin recorded whether a person asked for the change (meeting notes written on their own and notes a member asked for both log that member as the actor, so trigger is what tells them apart). " +
@@ -469,13 +498,15 @@ export function createServer(apiKey) {
469
498
  "SCOPE: the feed covers records, notes and tasks being created, updated, deleted and merged, plus files and folders being attached. It does NOT record list membership changes or detaching a document, so do not present it to the user as a complete audit trail.",
470
499
  inputSchema: {
471
500
  record_type: z.string().describe("Record type (company, person, deal, a custom object permalink, or task / note)"),
472
- id: z.string().uuid().describe("Record ID"),
501
+ record_id: z.string().uuid().optional().describe("Record ID whose feed to read"),
502
+ id: z.string().uuid().optional().describe("Same as record_id, the name this tool has always taken. Send either one."),
473
503
  limit: z.number().min(1).max(100).default(25).describe("Entries per page (max 100)"),
474
504
  offset: z.number().min(0).default(0).describe("Entries to skip. meta.total and meta.has_more say whether more remain"),
475
505
  },
476
- }, async ({ record_type, id, limit, offset }) => {
506
+ }, async ({ record_type, record_id, id, limit, offset }) => {
477
507
  try {
478
- const res = await api.get(`/records/${record_type}/${id}/activity`, { limit, offset });
508
+ const recordId = requireId(record_id, id, "record_id");
509
+ const res = await api.get(`/records/${record_type}/${recordId}/activity`, { limit, offset });
479
510
  return text(res);
480
511
  }
481
512
  catch (e) {
@@ -486,17 +517,19 @@ export function createServer(apiKey) {
486
517
  title: "List AI Reports on a Record",
487
518
  description: "The AI reports generated on one CRM record (any record type: company, person, deal, or a custom object permalink), newest first: the memos, SWOTs, teardowns, pass memos and briefs a member generated from the record's AI Reports tab in Lighthouse. " +
488
519
  "WHEN TO USE: the user asks whether a memo or report already exists on a record, what the last one said, wants to compare or build on an earlier report, or asks about a company's \"memo\" / \"analysis\" / \"write-up\" (check here before writing one from scratch). Not for dashboards or charts (those are lighthouse_reports_*), and nothing here generates a report: that happens in the app. " +
489
- "Each row: id, name, status (processing / success / error), depth (short / standard / extensive), template, partial (true when the run hit a cap), summary (one paragraph of the report's own prose), created_by { id, name }, created_at, completed_at. Only a success report has a document: read it with lighthouse_crm_get_ai_report. " +
520
+ "Each row: id, name, status (processing / success / error), depth (short / standard / extensive), template, partial (true when the run hit a cap), summary (one paragraph of the report's own prose), created_by ({ id, first_name, last_name, full_name, avatar_url, email }, the same member object every other row carries), created_at, completed_at. Only a success report has a document: read it with lighthouse_crm_get_ai_report. " +
490
521
  "A report quoting a mailbox, calendar, LinkedIn account or file the API key's user cannot read is left out of the list (its author always sees it).",
491
522
  inputSchema: {
492
523
  record_type: z.string().describe("Record type: company, person, deal, or a custom object permalink"),
493
- id: z.string().uuid().describe("Record ID"),
524
+ record_id: z.string().uuid().optional().describe("Record ID whose reports to list"),
525
+ id: z.string().uuid().optional().describe("Same as record_id, the name this tool has always taken. Send either one."),
494
526
  limit: z.number().min(1).max(50).default(25).describe("Reports per page (max 50)"),
495
527
  offset: z.number().min(0).default(0).describe("Reports to skip; meta.total and meta.has_more say whether more remain"),
496
528
  },
497
- }, async ({ record_type, id, limit, offset }) => {
529
+ }, async ({ record_type, record_id, id, limit, offset }) => {
498
530
  try {
499
- const res = await api.get(`/records/${encodeURIComponent(record_type)}/${id}/ai-reports`, { limit, offset });
531
+ const recordId = requireId(record_id, id, "record_id");
532
+ const res = await api.get(`/records/${encodeURIComponent(record_type)}/${recordId}/ai-reports`, { limit, offset });
500
533
  return text(res);
501
534
  }
502
535
  catch (e) {
@@ -524,7 +557,7 @@ export function createServer(apiKey) {
524
557
  title: "Create CRM Record",
525
558
  description: "Create a new CRM record. Pass all fields (standard + custom) in a single flat data object, " +
526
559
  "the API splits standard vs custom fields automatically. " +
527
- "BEFORE calling this tool, call lighthouse_crm_get_attributes to discover available field keys and types for the record_type. " +
560
+ "BEFORE calling this tool, call lighthouse_crm_list_attributes to discover available field keys and types for the record_type. " +
528
561
  "A name is strongly recommended for every record: company → name (string). person → fn or first_name (string). deal → name (string). " +
529
562
  "custom objects → name (string) is the only standard field; all other fields are custom. " +
530
563
  "If you omit the name, the record is still created but stored with the name \"Unnamed\", so always provide one when you can. " +
@@ -558,7 +591,7 @@ export function createServer(apiKey) {
558
591
  title: "Update CRM Record",
559
592
  description: "Update an existing CRM record. Only include the fields you want to change. " +
560
593
  "Standard and custom fields can be mixed in a single flat data object, the API splits them automatically. " +
561
- "BEFORE calling this tool, call lighthouse_crm_get_attributes to discover available field keys and types. " +
594
+ "BEFORE calling this tool, call lighthouse_crm_list_attributes to discover available field keys and types. " +
562
595
  "Relation and multi-value fields (domain, associated_people, associated_deals, emails, phones, associated_companies, and custom multi_select/multi_record/multi_user) " +
563
596
  "support two modes (company/person/deal ONLY; custom objects have only 'name' and custom fields): " +
564
597
  "(1) pass a bare ARRAY to REPLACE the whole field, e.g. domain: [\"acme.com\"], or an empty array [] to clear it (remove all); " +
@@ -595,19 +628,23 @@ export function createServer(apiKey) {
595
628
  title: "Upsert CRM Record",
596
629
  description: "Find-or-create a CRM record by a UNIQUE attribute: if a record already matches the value it is updated, otherwise a new one is created. " +
597
630
  "Only person and company support upsert, person matches on email or linkedin, company on domain or linkedin (set matching_attribute accordingly). " +
598
- "The value to match on MUST be present in data (e.g. with matching_attribute 'email', include emails: [\"jane@acme.com\"]; with 'domain', include domain: [\"acme.com\"]; with 'linkedin', include linkedin: \"...\"). " +
631
+ "Pass the value to match on as `matching_value` (\"jane@acme.com\", \"acme.com\", a LinkedIn address). It may also sit inside data as the attribute itself (emails: [\"jane@acme.com\"], domain: [\"acme.com\"], linkedin: \"...\"), which is what this tool used to require; matching_value wins when both carry it, and a request with neither is refused. A record created this way still stores the value. " +
599
632
  "All other field rules (including the { add, remove } append/remove syntax) are the same as lighthouse_crm_update_record. " +
600
633
  "Use this instead of create when you might be re-importing the same person/company and want to avoid duplicates.",
601
634
  inputSchema: {
602
635
  record_type: z.enum(["person", "company"]).describe("Only person or company can be upserted"),
603
636
  matching_attribute: z
604
- .enum(["email", "domain", "linkedin"])
605
- .describe("Unique attribute to match on: person → email or linkedin; company → domain or linkedin"),
606
- data: z.record(z.unknown()).describe("Record fields, MUST include the matching attribute's value so a created record carries it"),
637
+ .enum(["email", "emails", "domain", "linkedin"])
638
+ .describe("Unique attribute to match on: person → email or linkedin; company → domain or linkedin. emails (the field's permalink) is accepted as the same value as email"),
639
+ matching_value: z
640
+ .string()
641
+ .optional()
642
+ .describe("The value to match on (an email address, a domain like acme.com without https, or a LinkedIn address). Send it here rather than repeating it inside data; when it is absent the value is read from data"),
643
+ data: z.record(z.unknown()).describe("Record fields. It no longer has to repeat the matching attribute when matching_value carries it"),
607
644
  },
608
645
  }, async (params) => {
609
646
  try {
610
- const res = await api.put(`/records/${params.record_type}?matching_attribute=${params.matching_attribute}`, { data: params.data });
647
+ const res = await api.put(`/records/${params.record_type}?matching_attribute=${params.matching_attribute}`, params.matching_value ? { data: params.data, matching_value: params.matching_value } : { data: params.data });
611
648
  return text(res);
612
649
  }
613
650
  catch (e) {
@@ -634,7 +671,7 @@ export function createServer(apiKey) {
634
671
  title: "Bulk Create CRM Records",
635
672
  description: "Create up to 100 CRM records of the same record_type in one call. " +
636
673
  "Each item in `records` is a flat data object, the SAME shape you'd pass as `data` to lighthouse_crm_create_record. " +
637
- "BEFORE calling, call lighthouse_crm_get_attributes to discover field keys and types. " +
674
+ "BEFORE calling, call lighthouse_crm_list_attributes to discover field keys and types. " +
638
675
  "Partial success: the response returns a `results` array (one entry per item, in order, with success/data/error) and a `summary` " +
639
676
  "({total, succeeded, failed}). A failed item does NOT roll back the others. " +
640
677
  "Same per-record field rules as lighthouse_crm_create_record (domain as array, emails as array, owner as UUID, etc.).",
@@ -687,20 +724,20 @@ export function createServer(apiKey) {
687
724
  description: "Find-or-create up to 100 records of the same record_type in one call, each matched on a unique attribute. " +
688
725
  "For each item, if a record already matches the value it is updated, otherwise a new one is created. " +
689
726
  "Person matches on email or linkedin, company on domain or linkedin (set matching_attribute for the whole batch). " +
690
- "Each item in `records` is a flat data object that MUST include the matching attribute's value (e.g. matching_attribute 'email' means each item includes emails: [\"...\"]). " +
727
+ "The attribute is one per call; the VALUE differs per item, so it rides on the item: give each one a `matching_value` (\"jane@acme.com\"), or keep the older form and include the attribute itself (emails: [\"jane@acme.com\"]). The two mix freely in one batch, matching_value wins on an item that has both, and an item with neither comes back as a failed entry. matching_value is removed before the rest of the item becomes the record's fields. " +
691
728
  "Only person and company support upsert; deals and custom objects do not. " +
692
729
  "Partial success: the response returns a `results` array (one entry per item, in order, with success/created/data/error) and a `summary` ({total, succeeded, failed}). " +
693
730
  "Ideal for re-importing a batch of people or companies without creating duplicates.",
694
731
  inputSchema: {
695
732
  record_type: z.enum(["person", "company"]).describe("Only person or company can be upserted"),
696
733
  matching_attribute: z
697
- .enum(["email", "domain", "linkedin"])
698
- .describe("Unique attribute to match on for ALL items: person uses email or linkedin; company uses domain or linkedin"),
734
+ .enum(["email", "emails", "domain", "linkedin"])
735
+ .describe("Unique attribute to match on for ALL items: person uses email or linkedin; company uses domain or linkedin. emails (the field's permalink) is accepted as the same value as email"),
699
736
  records: z
700
737
  .array(z.record(z.unknown()))
701
738
  .min(1)
702
739
  .max(100)
703
- .describe("Array of flat data objects (max 100); each MUST include the matching attribute's value"),
740
+ .describe("Array of flat data objects (max 100); each carries the value to match on as matching_value, or as the matching attribute inside the item"),
704
741
  },
705
742
  }, async (params) => {
706
743
  try {
@@ -752,7 +789,8 @@ export function createServer(apiKey) {
752
789
  });
753
790
  server.registerTool("lighthouse_crm_list_lists", {
754
791
  title: "List CRM Lists",
755
- description: "Get all CRM lists in your workspace. Each list's record_type is 'company', 'person', 'deal', or a custom object permalink (e.g. 'lp', 'project'), and that is the record type of everything in it.",
792
+ description: "Get all CRM lists in your workspace. Each list's record_type is 'company', 'person', 'deal', or a custom object permalink (e.g. 'lp', 'project'), and that is the record type of everything in it. " +
793
+ "Each row carries owner (the member who created it) and is_owner (true when that member is the API key's user), so 'my lists' and 'lists shared with me' can be told apart without a second call.",
756
794
  inputSchema: {
757
795
  limit: z.number().min(1).max(100).default(25),
758
796
  offset: z.number().min(0).default(0),
@@ -768,13 +806,15 @@ export function createServer(apiKey) {
768
806
  });
769
807
  server.registerTool("lighthouse_crm_get_list", {
770
808
  title: "Get CRM List",
771
- description: "Get a single CRM list by ID with its configuration. Its record_type is 'company', 'person', 'deal', or a custom object permalink (e.g. 'lp', 'project').",
809
+ description: "Get a single CRM list by ID with its configuration. Its record_type is 'company', 'person', 'deal', or a custom object permalink (e.g. 'lp', 'project'). " +
810
+ "is_owner says whether the API key's user owns the list, so 'my lists' and 'lists shared with me' can be told apart without a second call. A list you cannot open answers 'List not found', the same as one that does not exist.",
772
811
  inputSchema: {
773
- id: z.string().uuid().describe("List ID"),
812
+ list_id: z.string().uuid().optional().describe("List ID"),
813
+ id: z.string().uuid().optional().describe("Same as list_id, the name this tool has always taken. Send either one."),
774
814
  },
775
815
  }, async (params) => {
776
816
  try {
777
- const res = await api.get(`/lists/${params.id}`);
817
+ const res = await api.get(`/lists/${requireId(params.list_id, params.id, "list_id")}`);
778
818
  return text(res);
779
819
  }
780
820
  catch (e) {
@@ -783,17 +823,18 @@ export function createServer(apiKey) {
783
823
  });
784
824
  server.registerTool("lighthouse_crm_update_list", {
785
825
  title: "Update CRM List",
786
- description: "Update a CRM list's name or configuration.",
826
+ description: "Update a CRM list's name or configuration. A list you cannot open answers 'List not found', the same as one that does not exist.",
787
827
  inputSchema: {
788
- id: z.string().uuid().describe("List ID"),
828
+ list_id: z.string().uuid().optional().describe("List ID"),
829
+ id: z.string().uuid().optional().describe("Same as list_id, the name this tool has always taken. Send either one."),
789
830
  name: z.string().optional().describe("New list name"),
790
831
  sharing: z.enum(["private", "workspace"]).optional().describe("New visibility setting"),
791
832
  color: z.string().optional().describe("New hex color code"),
792
833
  },
793
834
  }, async (params) => {
794
835
  try {
795
- const { id, ...body } = params;
796
- const res = await api.patch(`/lists/${id}`, body);
836
+ const { list_id, id, ...body } = params;
837
+ const res = await api.patch(`/lists/${requireId(list_id, id, "list_id")}`, body);
797
838
  return text(res);
798
839
  }
799
840
  catch (e) {
@@ -802,26 +843,29 @@ export function createServer(apiKey) {
802
843
  });
803
844
  server.registerTool("lighthouse_crm_delete_list", {
804
845
  title: "Delete CRM List",
805
- description: "Permanently delete a CRM list.",
846
+ description: "Permanently delete a CRM list. A list you cannot open answers 'List not found', the same as one that does not exist.",
806
847
  inputSchema: {
807
- id: z.string().uuid().describe("List ID"),
848
+ list_id: z.string().uuid().optional().describe("List ID"),
849
+ id: z.string().uuid().optional().describe("Same as list_id, the name this tool has always taken. Send either one."),
808
850
  },
809
851
  }, async (params) => {
810
852
  try {
811
- const res = await api.delete(`/lists/${params.id}`);
853
+ const res = await api.delete(`/lists/${requireId(params.list_id, params.id, "list_id")}`);
812
854
  return text(res);
813
855
  }
814
856
  catch (e) {
815
857
  return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
816
858
  }
817
859
  });
818
- server.registerTool("lighthouse_crm_get_list_records", {
819
- title: "Get List Records",
860
+ server.registerTool("lighthouse_crm_list_list_records", {
861
+ title: "List Records in a List",
820
862
  description: "Get records in a CRM list with optional filtering, sorting, and view support. " +
821
- "Same filter format as search_records. To get valid filter keys, call lighthouse_crm_get_attributes with this list's record_type, which is 'company', 'person', 'deal', or a custom object permalink. " +
822
- "The response's `meta` includes `total` (an integer match count) and `ignored_filter_fields` (any filter key that is not a valid attribute for this list's record type, each with a did_you_mean suggestion when a close match can be computed). Check `ignored_filter_fields` to confirm every filter was actually applied.",
863
+ "Same filter format as search_records. To get valid filter keys, call lighthouse_crm_list_attributes with this list's record_type, which is 'company', 'person', 'deal', or a custom object permalink. " +
864
+ "The response's `meta` includes `total` (an integer match count), `ignored_filter_fields` (any filter key that is not a valid attribute for this list's record type, each with a did_you_mean suggestion when a close match can be computed) and `invalid_sort_fields` (a sort this list cannot apply, reported rather than refused, so the records come back in their default order). Check both arrays to confirm every filter and sort was actually applied. " +
865
+ "A list you cannot open answers 'List not found', the same as one that does not exist, and so does a condition or a sort on a custom field that belongs to such a list.",
823
866
  inputSchema: {
824
- id: z.string().uuid().describe("List ID"),
867
+ list_id: z.string().uuid().optional().describe("List ID"),
868
+ id: z.string().uuid().optional().describe("Same as list_id, the name this tool has always taken. Send either one."),
825
869
  filters: z.record(z.unknown()).optional().describe(CRM_FILTER_DESCRIPTION),
826
870
  sort: z.array(z.record(z.unknown())).optional().describe(CRM_SORT_DESCRIPTION),
827
871
  limit: z.number().min(1).max(100).default(25),
@@ -830,8 +874,8 @@ export function createServer(apiKey) {
830
874
  },
831
875
  }, async (params) => {
832
876
  try {
833
- const { id, ...body } = params;
834
- const res = await api.post(`/lists/${id}/records/search`, body);
877
+ const { list_id, id, ...body } = params;
878
+ const res = await api.post(`/lists/${requireId(list_id, id, "list_id")}/records/search`, body);
835
879
  return text(res);
836
880
  }
837
881
  catch (e) {
@@ -840,7 +884,7 @@ export function createServer(apiKey) {
840
884
  });
841
885
  server.registerTool("lighthouse_crm_add_to_list", {
842
886
  title: "Add Record to List",
843
- description: "Add a CRM record to a list. The record type is inferred from the list, so the record must be of the list's record_type: 'company', 'person', 'deal', or a custom object permalink.",
887
+ description: "Add a CRM record to a list. The record type is inferred from the list, so the record must be of the list's record_type: 'company', 'person', 'deal', or a custom object permalink. Answers `reason` beside the flag: the sentence \"This record was already on the list.\" / \"This record was not on the list.\" when the call changed nothing, and null when it did. Both writes succeed either way, so this key is what tells a no-op from a real change.",
844
888
  inputSchema: {
845
889
  list_id: z.string().uuid().describe("List ID"),
846
890
  record_id: z.string().uuid().describe("Record ID to add"),
@@ -858,7 +902,7 @@ export function createServer(apiKey) {
858
902
  });
859
903
  server.registerTool("lighthouse_crm_remove_from_list", {
860
904
  title: "Remove Record from List",
861
- description: "Remove a record from a CRM list. The record type is inferred from the list, whose record_type is 'company', 'person', 'deal', or a custom object permalink.",
905
+ description: "Remove a record from a CRM list. The record type is inferred from the list, whose record_type is 'company', 'person', 'deal', or a custom object permalink. Answers `reason` beside the flag: the sentence \"This record was already on the list.\" / \"This record was not on the list.\" when the call changed nothing, and null when it did. Both writes succeed either way, so this key is what tells a no-op from a real change.",
862
906
  inputSchema: {
863
907
  list_id: z.string().uuid().describe("List ID"),
864
908
  record_id: z.string().uuid().describe("Record ID to remove"),
@@ -876,7 +920,7 @@ export function createServer(apiKey) {
876
920
  title: "Bulk Add Records to List",
877
921
  description: "Add up to 100 CRM records to a list in one call. The record type is inferred from the list, so every id must be of the list's record_type: 'company', 'person', 'deal', or a custom object permalink. " +
878
922
  "Partial success: the response returns a `results` array (one entry per id, in order, with success/error) and a `summary` ({total, succeeded, failed}). " +
879
- "A failed id does NOT prevent the others from being added.",
923
+ "A failed id does NOT prevent the others from being added. Every result entry also carries `reason`, ONE key for \"why did this record not change\": the no-op sentence on a success that wrote nothing, the refusal in words on a failure, null when the record really changed. `error` stays beside it for a client that branches on a code.",
880
924
  inputSchema: {
881
925
  list_id: z.string().uuid().describe("List ID"),
882
926
  record_ids: z.array(z.string().uuid()).min(1).max(100).describe("Array of record UUIDs to add (max 100)"),
@@ -893,7 +937,7 @@ export function createServer(apiKey) {
893
937
  server.registerTool("lighthouse_crm_bulk_remove_from_list", {
894
938
  title: "Bulk Remove Records from List",
895
939
  description: "Remove up to 100 CRM records from a list in one call. Every id must be of the list's record_type: 'company', 'person', 'deal', or a custom object permalink. " +
896
- "Partial success: the response returns a `results` array (one entry per id, in order, with success/error) and a `summary` ({total, succeeded, failed}).",
940
+ "Partial success: the response returns a `results` array (one entry per id, in order, with success/error) and a `summary` ({total, succeeded, failed}). Every result entry also carries `reason`, ONE key for \"why did this record not change\": the no-op sentence on a success that wrote nothing, the refusal in words on a failure, null when the record really changed. `error` stays beside it for a client that branches on a code.",
897
941
  inputSchema: {
898
942
  list_id: z.string().uuid().describe("List ID"),
899
943
  record_ids: z.array(z.string().uuid()).min(1).max(100).describe("Array of record UUIDs to remove (max 100)"),
@@ -912,15 +956,16 @@ export function createServer(apiKey) {
912
956
  description: "Grant team-level access to a CRM list. Only the list owner can share. " +
913
957
  "Use lighthouse_workspace_list_teams to find team IDs first. " +
914
958
  "Requires lists.share permission. " +
915
- "Returns formatted list: {id, name, record_type, sharing, color, count, owner, shared_with_teams, created_at, updated_at}, where record_type is 'company', 'person', 'deal', or a custom object permalink.",
959
+ "Returns formatted list: {id, name, record_type, sharing, color, count, owner, is_owner, shared_with_teams, created_at, updated_at}, where record_type is 'company', 'person', 'deal', or a custom object permalink and is_owner says whether the API key's user owns the list.",
916
960
  inputSchema: {
917
- id: z.string().uuid().describe("List ID"),
961
+ list_id: z.string().uuid().optional().describe("List ID"),
962
+ id: z.string().uuid().optional().describe("Same as list_id, the name this tool has always taken. Send either one."),
918
963
  team_ids: z.array(z.string().uuid()).describe("Array of team UUIDs to grant access to"),
919
964
  },
920
965
  }, async (params) => {
921
966
  try {
922
- const { id, ...body } = params;
923
- const res = await api.post(`/lists/${id}/teams`, body);
967
+ const { list_id, id, ...body } = params;
968
+ const res = await api.post(`/lists/${requireId(list_id, id, "list_id")}/teams`, body);
924
969
  return text(res);
925
970
  }
926
971
  catch (e) {
@@ -931,15 +976,16 @@ export function createServer(apiKey) {
931
976
  title: "Revoke List Team Access",
932
977
  description: "Revoke team-level access from a CRM list. Only the list owner can revoke. " +
933
978
  "Requires lists.share permission. " +
934
- "Returns formatted list: {id, name, record_type, sharing, color, count, owner, shared_with_teams, created_at, updated_at}, where record_type is 'company', 'person', 'deal', or a custom object permalink.",
979
+ "Returns formatted list: {id, name, record_type, sharing, color, count, owner, is_owner, shared_with_teams, created_at, updated_at}, where record_type is 'company', 'person', 'deal', or a custom object permalink and is_owner says whether the API key's user owns the list.",
935
980
  inputSchema: {
936
- id: z.string().uuid().describe("List ID"),
981
+ list_id: z.string().uuid().optional().describe("List ID"),
982
+ id: z.string().uuid().optional().describe("Same as list_id, the name this tool has always taken. Send either one."),
937
983
  team_ids: z.array(z.string().uuid()).describe("Array of team UUIDs to revoke access from"),
938
984
  },
939
985
  }, async (params) => {
940
986
  try {
941
- const { id, ...body } = params;
942
- const res = await api.delete(`/lists/${id}/teams`, body);
987
+ const { list_id, id, ...body } = params;
988
+ const res = await api.delete(`/lists/${requireId(list_id, id, "list_id")}/teams`, body);
943
989
  return text(res);
944
990
  }
945
991
  catch (e) {
@@ -950,15 +996,16 @@ export function createServer(apiKey) {
950
996
  title: "Update List Sharing Status",
951
997
  description: "Change a CRM list's sharing visibility. Setting to 'private' also removes all team permissions. " +
952
998
  "Only the list owner can change sharing. Requires lists.share permission. " +
953
- "Returns formatted list: {id, name, record_type, sharing, color, count, owner, shared_with_teams, created_at, updated_at}, where record_type is 'company', 'person', 'deal', or a custom object permalink.",
999
+ "Returns formatted list: {id, name, record_type, sharing, color, count, owner, is_owner, shared_with_teams, created_at, updated_at}, where record_type is 'company', 'person', 'deal', or a custom object permalink and is_owner says whether the API key's user owns the list.",
954
1000
  inputSchema: {
955
- id: z.string().uuid().describe("List ID"),
1001
+ list_id: z.string().uuid().optional().describe("List ID"),
1002
+ id: z.string().uuid().optional().describe("Same as list_id, the name this tool has always taken. Send either one."),
956
1003
  sharing: z.enum(["private", "workspace"]).describe("New sharing visibility"),
957
1004
  },
958
1005
  }, async (params) => {
959
1006
  try {
960
- const { id, ...body } = params;
961
- const res = await api.patch(`/lists/${id}/sharing`, body);
1007
+ const { list_id, id, ...body } = params;
1008
+ const res = await api.patch(`/lists/${requireId(list_id, id, "list_id")}/sharing`, body);
962
1009
  return text(res);
963
1010
  }
964
1011
  catch (e) {
@@ -968,6 +1015,7 @@ export function createServer(apiKey) {
968
1015
  server.registerTool("lighthouse_crm_list_notes", {
969
1016
  title: "List Notes",
970
1017
  description: "Get notes for a CRM record. Each note in the response includes a `records` map, every record (company/person/deal/custom object) the note is currently linked to, plus a `tags` array of any workspace tags attached to the note (each with id, label, color). " +
1018
+ "`tagged_users` is the teammates the note MENTIONS, each the member object this API answers for a person ({ id, first_name, last_name, full_name, avatar_url, email }), and [] when it mentions nobody. It is read from the mention spans in `content`, not from the ids a write sent, so a note written with tagged_users ids and no mention span reads back []: always write both, as lighthouse_crm_create_note and lighthouse_crm_update_note say. " +
971
1019
  "Every note also carries `type` ('standard' for a note a member wrote, 'meeting' for meeting notes written from a recorded meeting) and `meeting` (null on a standard note; on a meeting note { title, occurred_at, duration_seconds, call_type, ical_uid, attendees, has_transcript, has_recording }, where attendees is [{ email, name }] with name possibly null, call_type is a short category from a fixed list such as 'First founder call' or 'Internal discussion', and duration_seconds / ical_uid / attendees are omitted when unknown). Meeting notes are included only when the API key's user can open the meeting's recording.",
972
1020
  inputSchema: {
973
1021
  record_id: z.string().uuid().describe("Record ID to list notes for"),
@@ -994,6 +1042,7 @@ export function createServer(apiKey) {
994
1042
  "records (linked record ids: contains_any/not_contains_any/contains_all/empty/not_empty; pass a plain array of record ids of ANY type, custom object records included, or an object keyed by record type or custom object permalink {company:[ids],person:[ids],deal:[ids],\"<permalink>\":[ids]}), " +
995
1043
  "type (select: contains_any / not_contains_any ONLY, with an array of \"standard\" and / or \"meeting\"; [\"meeting\"] selects meeting notes written from a recorded meeting, [\"standard\"] the notes members wrote; any other value is rejected). " +
996
1044
  "An unknown field or invalid operator is rejected with a clear error naming it. " +
1045
+ "`tagged_users` is the teammates the note MENTIONS, each the member object this API answers for a person ({ id, first_name, last_name, full_name, avatar_url, email }), and [] when it mentions nobody. It is read from the mention spans in `content`, not from the ids a write sent, so a note written with tagged_users ids and no mention span reads back []: always write both, as lighthouse_crm_create_note and lighthouse_crm_update_note say. " +
997
1046
  "Every returned note carries `type` ('standard' | 'meeting') and `meeting` (null on a standard note; on a meeting note { title, occurred_at, duration_seconds, call_type, ical_uid, attendees, has_transcript, has_recording }; attendees is [{ email, name }] with name possibly null; duration_seconds, ical_uid and attendees are omitted when unknown). Meeting notes are included only when the API key's user can open the meeting's recording.",
998
1047
  inputSchema: {
999
1048
  search: z.string().optional().describe("Free-text search on the note title"),
@@ -1053,13 +1102,15 @@ export function createServer(apiKey) {
1053
1102
  server.registerTool("lighthouse_crm_get_note", {
1054
1103
  title: "Get Note",
1055
1104
  description: "Get a single note by ID. Response includes a `records` object keyed by record type (or custom object permalink) with every linked record, plus a `tags` array of any workspace tags attached to the note (each with permalink, label, color). " +
1105
+ "`tagged_users` is the teammates the note MENTIONS, each the member object this API answers for a person ({ id, first_name, last_name, full_name, avatar_url, email }), and [] when it mentions nobody. It is read from the mention spans in `content`, not from the ids a write sent, so a note written with tagged_users ids and no mention span reads back []: always write both, as lighthouse_crm_create_note and lighthouse_crm_update_note say. " +
1056
1106
  "`type` is 'standard' for a note a member wrote and 'meeting' for meeting notes written from a recorded meeting; `meeting` is null on a standard note and { title, occurred_at, duration_seconds, call_type, ical_uid, attendees, has_transcript, has_recording } on a meeting note: title and occurred_at come from the meeting itself, attendees is [{ email, name }] with name possibly null, call_type is one of 'First founder call', 'Follow-up founder call', 'Diligence call', 'Re-engagement', 'Portfolio check-in', 'Board meeting', 'Investment committee', 'LP meeting', 'Fundraising call', 'Co-investor or syndication call', 'Internal discussion', 'Hiring or reference call', 'Partnership or vendor call' or 'Other', and duration_seconds / ical_uid / attendees are omitted when unknown. A meeting note is returned only when the API key's user can open the meeting's recording; otherwise it is reported as not found.",
1057
1107
  inputSchema: {
1058
- id: z.string().uuid().describe("Note ID"),
1108
+ note_id: z.string().uuid().optional().describe("Note ID"),
1109
+ id: z.string().uuid().optional().describe("Same as note_id, the name this tool has always taken. Send either one."),
1059
1110
  },
1060
1111
  }, async (params) => {
1061
1112
  try {
1062
- const res = await api.get(`/notes/${params.id}`);
1113
+ const res = await api.get(`/notes/${requireId(params.note_id, params.id, "note_id")}`);
1063
1114
  return text(res);
1064
1115
  }
1065
1116
  catch (e) {
@@ -1075,18 +1126,19 @@ export function createServer(apiKey) {
1075
1126
  '<span data-type="mention" data-id="USER_UUID" data-label="First Last">@First Last</span>. ' +
1076
1127
  "A user newly named in tagged_users receives one in-app notification; keep the spans of people already mentioned when you resend the content. " +
1077
1128
  "tags are the workspace's labeled, colored note tags (permalinks from lighthouse_crm_list_note_tags), separate from tagged_users: the array REPLACES the tag set (an empty array clears it); to add or remove single tags without touching the rest use lighthouse_crm_manage_note_tags. " +
1078
- "Works on meeting notes too: the edit is kept on the current version and the note reports it as edited. " +
1129
+ "Works on meeting notes too: the edit is kept on the current version, and lighthouse_inbox_calendar_meeting_notes then reports is_edited true for that meeting. " +
1079
1130
  "Returns the updated note in the same shape as lighthouse_crm_get_note. To change which records a note is attached to use lighthouse_crm_link_note_record / lighthouse_crm_unlink_note_record; to remove it use lighthouse_crm_delete_note.",
1080
1131
  inputSchema: {
1081
- id: z.string().uuid().describe("Note ID"),
1132
+ note_id: z.string().uuid().optional().describe("Note ID"),
1133
+ id: z.string().uuid().optional().describe("Same as note_id, the name this tool has always taken. Send either one."),
1082
1134
  title: z.string().optional().describe("New title"),
1083
1135
  content: z.string().optional().describe("New content, replacing the whole body. HTML (<p> paragraphs, <ul><li> bullets) or plain text. For @mentions include mention spans (see description)."),
1084
1136
  tags: z.array(z.string()).optional().describe("Workspace tag permalinks to set on the note (from lighthouse_crm_list_note_tags). Replaces the current set; [] clears it. Distinct from tagged_users."),
1085
1137
  tagged_users: z.array(z.string().uuid()).optional().describe("Workspace user UUIDs to @mention in the note. Must match mention spans in content."),
1086
1138
  },
1087
- }, async ({ id, ...changes }) => {
1139
+ }, async ({ note_id, id, ...changes }) => {
1088
1140
  try {
1089
- const res = await api.patch(`/notes/${id}`, changes);
1141
+ const res = await api.patch(`/notes/${requireId(note_id, id, "note_id")}`, changes);
1090
1142
  return text(res);
1091
1143
  }
1092
1144
  catch (e) {
@@ -1097,11 +1149,12 @@ export function createServer(apiKey) {
1097
1149
  title: "Delete Note",
1098
1150
  description: "Permanently delete a note and all of its record links. Meeting notes are deleted the same way, together with every version of them; the meeting's recording and transcript are not affected.",
1099
1151
  inputSchema: {
1100
- id: z.string().uuid().describe("Note ID"),
1152
+ note_id: z.string().uuid().optional().describe("Note ID"),
1153
+ id: z.string().uuid().optional().describe("Same as note_id, the name this tool has always taken. Send either one."),
1101
1154
  },
1102
1155
  }, async (params) => {
1103
1156
  try {
1104
- const res = await api.delete(`/notes/${params.id}`);
1157
+ const res = await api.delete(`/notes/${requireId(params.note_id, params.id, "note_id")}`);
1105
1158
  return text(res);
1106
1159
  }
1107
1160
  catch (e) {
@@ -1165,7 +1218,7 @@ export function createServer(apiKey) {
1165
1218
  title: "Create Note Tag",
1166
1219
  description: "Create a new note tag in the workspace vocabulary. The tag can then be attached to notes. " +
1167
1220
  "Returns the created tag as { permalink, label, color }. " +
1168
- "Color is an optional palette code (get available colors from lighthouse_crm_get_color_palette); omit it to auto-assign one.",
1221
+ "Color is an optional palette code (get available colors from lighthouse_crm_list_colors); omit it to auto-assign one.",
1169
1222
  inputSchema: {
1170
1223
  label: z.string().describe("Tag display label"),
1171
1224
  color: z.string().optional().describe("Tag color (palette code, auto-assigned if omitted)"),
@@ -1237,7 +1290,8 @@ export function createServer(apiKey) {
1237
1290
  description: "List CRM tasks with optional filtering and sorting. " +
1238
1291
  'filters (CRM-style): {"operator":"AND","conditions":[{"field":"status","operator":"contains_any","value":["<status_value>"]}]} (status AND priority are option fields whose values are workspace-specific; fetch them with lighthouse_crm_list_options(record_type:\'task\', permalink:\'status\' | \'priority\'), and filter priority with contains_any like status, not with a numeric comparison); ' +
1239
1292
  'sort: [{"field":"due_date","direction":"asc"}]; ' +
1240
- "Filterable fields: title, status, priority, due_date, description, assigned_to, records (person/company/deal/custom object permalink), created_by (the teammate who created the task, filtered like any user field), created_at, time_in_status (read-only, a whole number of days in the current status). Tasks have no custom fields: any other field name is refused with a 400.",
1293
+ "Filterable fields: title, status, priority, due_date, description, assigned_to, records (person/company/deal/custom object permalink), created_by (the teammate who created the task, filtered like any user field), created_at, time_in_status (read-only, a whole number of days in the current status). Tasks have no custom fields: any other field name is refused with a 400. " +
1294
+ "status and priority answer the stored VALUE and nothing else. The words your workspace puts on those values come from lighthouse_crm_list_options(record_type:'task', permalink:'status' | 'priority'), one call that covers every task you will ever read.",
1241
1295
  inputSchema: {
1242
1296
  filters: z.record(z.any()).optional().describe("CRM filter object for tasks. Filterable fields: title, status, priority, due_date, description, assigned_to, records (person/company/deal/custom object permalink), created_by (the teammate who created the task, filtered like any user field), created_at, time_in_status (read-only, a whole number of days in the current status). Tasks have no custom fields: any other field name is refused with a 400. " + CRM_FILTER_DESCRIPTION),
1243
1297
  sort: z.array(z.record(z.any())).optional().describe('Task sort array: [{"field":"<key>","direction":"asc"|"desc"}], applied in order (default: due_date asc). ' +
@@ -1259,7 +1313,8 @@ export function createServer(apiKey) {
1259
1313
  });
1260
1314
  server.registerTool("lighthouse_crm_get_task", {
1261
1315
  title: "Get Task",
1262
- description: "Get a single CRM task by ID.",
1316
+ description: "Get a single CRM task by ID. " +
1317
+ "status and priority answer the stored VALUE and nothing else. The words your workspace puts on those values come from lighthouse_crm_list_options(record_type:'task', permalink:'status' | 'priority'), one call that covers every task you will ever read.",
1263
1318
  inputSchema: {
1264
1319
  id: z.string().uuid().describe("Task ID"),
1265
1320
  },
@@ -1275,7 +1330,8 @@ export function createServer(apiKey) {
1275
1330
  server.registerTool("lighthouse_crm_create_task", {
1276
1331
  title: "Create Task",
1277
1332
  description: "Create a CRM task, optionally linked to records and assigned to users. " +
1278
- "Status values are configured per workspace and are NOT fixed: call lighthouse_crm_list_options with record_type 'task' and permalink 'status' to get the allowed values, then pass the option's value (not its label) in `status`.",
1333
+ "Status values are configured per workspace and are NOT fixed: call lighthouse_crm_list_options with record_type 'task' and permalink 'status' to get the allowed values, then pass the option's value (not its label) in `status`. " +
1334
+ "status and priority answer the stored VALUE and nothing else. The words your workspace puts on those values come from lighthouse_crm_list_options(record_type:'task', permalink:'status' | 'priority'), one call that covers every task you will ever read.",
1279
1335
  inputSchema: {
1280
1336
  title: z.string().describe("Task title"),
1281
1337
  description: z.string().optional().describe("Task description"),
@@ -1289,7 +1345,7 @@ export function createServer(apiKey) {
1289
1345
  records: z
1290
1346
  .record(z.array(z.string().uuid()))
1291
1347
  .optional()
1292
- .describe("Records to link, keyed by type: {company: [ids], person: [ids], deal: [ids], <custom_object_permalink>: [ids]}"),
1348
+ .describe("Records to link, keyed by type: {company: [ids], person: [ids], deal: [ids]}; a custom object's records go under its permalink (e.g. lp: [ids])"),
1293
1349
  },
1294
1350
  }, async (params) => {
1295
1351
  try {
@@ -1304,7 +1360,8 @@ export function createServer(apiKey) {
1304
1360
  title: "Update Task",
1305
1361
  description: "Update a CRM task. Provide only fields you want to change. " +
1306
1362
  "assigned_to replaces all current assignees. records replaces all linked records. " +
1307
- "Status values are configured per workspace and are NOT fixed: call lighthouse_crm_list_options with record_type 'task' and permalink 'status' to get the allowed values, then pass the option's value (not its label) in `status`.",
1363
+ "Status values are configured per workspace and are NOT fixed: call lighthouse_crm_list_options with record_type 'task' and permalink 'status' to get the allowed values, then pass the option's value (not its label) in `status`. " +
1364
+ "status and priority answer the stored VALUE and nothing else. The words your workspace puts on those values come from lighthouse_crm_list_options(record_type:'task', permalink:'status' | 'priority'), one call that covers every task you will ever read.",
1308
1365
  inputSchema: {
1309
1366
  id: z.string().uuid().describe("Task ID"),
1310
1367
  title: z.string().optional(),
@@ -1319,7 +1376,7 @@ export function createServer(apiKey) {
1319
1376
  records: z
1320
1377
  .record(z.array(z.string().uuid()))
1321
1378
  .optional()
1322
- .describe("Records to link (replaces current), keyed by type: {company: [ids], person: [ids], deal: [ids], <custom_object_permalink>: [ids]}"),
1379
+ .describe("Records to link (replaces current), keyed by type: {company: [ids], person: [ids], deal: [ids]}; a custom object's records go under its permalink (e.g. lp: [ids])"),
1323
1380
  },
1324
1381
  }, async (params) => {
1325
1382
  try {
@@ -1346,14 +1403,16 @@ export function createServer(apiKey) {
1346
1403
  return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
1347
1404
  }
1348
1405
  });
1349
- server.registerTool("lighthouse_list_documents", {
1406
+ server.registerTool("lighthouse_documents_list", {
1350
1407
  title: "List Documents",
1351
1408
  description: "List folders and files at the workspace root, or inside a specific folder. " +
1352
1409
  "Returns { folders, files, breadcrumb } plus meta.folders_next_cursor / " +
1353
1410
  "meta.files_next_cursor for cursor-based pagination. Pass those cursors " +
1354
1411
  "back on the next call to get the next page; null means exhausted. " +
1355
1412
  "Each row includes my_role ('owner' | 'editor' | 'viewer'), is_owner, " +
1356
- "owner_name, and shared_with_teams[] (each entry has { team_id, role }).",
1413
+ "owner (the member who owns it: { id, first_name, last_name, full_name, avatar_url, email }), " +
1414
+ "sharing ('private' or 'workspace'), linked_records_count, and " +
1415
+ "shared_with_teams[] (each entry has { team_id, team_name, role, granted_at }).",
1357
1416
  inputSchema: {
1358
1417
  folder_id: z.string().uuid().optional().describe("Folder UUID to list contents of (omit for workspace root)"),
1359
1418
  limit: z.number().int().min(1).max(500).optional().describe("Rows per sub-list (folders, files). Default 200, max 500"),
@@ -1369,16 +1428,18 @@ export function createServer(apiKey) {
1369
1428
  return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
1370
1429
  }
1371
1430
  });
1372
- server.registerTool("lighthouse_create_folder", {
1431
+ server.registerTool("lighthouse_documents_create_folder", {
1373
1432
  title: "Create Folder",
1374
- description: "Create a folder. At the workspace root, sharing_status is required. " +
1375
- "Inside a parent folder, sharing is automatically inherited from the parent.",
1433
+ description: "Create a folder. At the workspace root, sharing is required. " +
1434
+ "Inside a parent folder, sharing is automatically inherited from the parent. " +
1435
+ "The folder row comes back with owner (the member who created it, { id, first_name, last_name, full_name, avatar_url, email }), is_owner, my_role, sharing ('private' or 'workspace'), shared_with_teams[] (each { team_id, team_name, role, granted_at }), linked_records_count, created_at and updated_at.",
1376
1436
  inputSchema: {
1377
1437
  name: z.string().describe("Folder name"),
1378
1438
  parent_folder_id: z.string().uuid().optional().describe("Parent folder UUID, omit for root"),
1379
- sharing_status: z.enum(["PRIVATE", "WORKSPACE"]).optional().describe("Required at root, ignored inside a parent"),
1380
- team_ids: z.array(z.string().uuid()).optional().describe("Team UUIDs to grant access when sharing_status=PRIVATE"),
1381
- role: z.enum(["viewer", "editor"]).optional().describe("Role applied to team_ids grants. Defaults to 'viewer'. Viewer grants on WORKSPACE folders are rejected as redundant."),
1439
+ sharing: z.enum(["private", "workspace"]).optional().describe("Required at root, ignored inside a parent. The same word every other sharing tool takes, and the one a folder answers."),
1440
+ sharing_status: z.enum(["PRIVATE", "WORKSPACE"]).optional().describe("Legacy name for sharing. Kept forever; sharing wins when both are sent."),
1441
+ team_ids: z.array(z.string().uuid()).optional().describe("Team UUIDs to grant access when sharing is private"),
1442
+ role: z.enum(["viewer", "editor"]).optional().describe("Role applied to team_ids grants. Defaults to 'viewer'. Viewer grants on a workspace folder are rejected as redundant."),
1382
1443
  },
1383
1444
  }, async (params) => {
1384
1445
  try {
@@ -1389,18 +1450,39 @@ export function createServer(apiKey) {
1389
1450
  return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
1390
1451
  }
1391
1452
  });
1392
- server.registerTool("lighthouse_update_folder", {
1453
+ server.registerTool("lighthouse_documents_get_folder", {
1454
+ title: "Get Folder",
1455
+ description: "WHEN TO USE: the user asks about ONE folder they already know (\"who can see the Investor Updates folder\", \"what is that folder linked to\"), or you have a folder id from lighthouse_documents_list and need its full row. " +
1456
+ "Returns the folder itself, not its contents: name, parent_folder_id, sharing ('private' or 'workspace'), shared_with_teams[] (each { team_id, team_name, role, granted_at }), is_owner, my_role ('owner' | 'editor' | 'viewer'), owner ({ id, first_name, last_name, full_name, avatar_url, email }), linked_records_count, linked_records[] (every link you can view, each { record_type, record_id, record_name, avatar_url, created_at }), created_at and updated_at. " +
1457
+ "To list what is INSIDE it, call lighthouse_documents_list with this folder_id. A folder you cannot open answers the same not found as one that does not exist.",
1458
+ inputSchema: {
1459
+ folder_id: z.string().uuid().optional().describe("Folder UUID. The name every other folder tool takes."),
1460
+ id: z.string().uuid().optional().describe("Same as folder_id, the name the tool this one is named after would take. Send either one."),
1461
+ },
1462
+ }, async (params) => {
1463
+ try {
1464
+ const res = await api.get(`/v1/documents/folders/${requireId(params.folder_id, params.id, "folder_id")}`);
1465
+ return text(res);
1466
+ }
1467
+ catch (e) {
1468
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
1469
+ }
1470
+ });
1471
+ server.registerTool("lighthouse_documents_update_folder", {
1393
1472
  title: "Update Folder",
1394
1473
  description: "Rename, move, or change sharing on a folder. Editors can rename the folder; only owners can move it, change its sharing, or grant team access. " +
1395
- "Pass team_ids as the full desired list, anything not in the list is revoked.",
1474
+ "Pass team_ids as the full desired list, anything not in the list is revoked. " +
1475
+ "Note the two meanings of folder_id across the document tools: here it is the folder being changed, while on lighthouse_documents_update_file it is the folder the file is moved INTO. " +
1476
+ "Returns the folder row: owner, is_owner, my_role, sharing, shared_with_teams[] (each { team_id, team_name, role, granted_at }), linked_records_count, created_at, updated_at.",
1396
1477
  inputSchema: {
1397
- folder_id: z.string().uuid().describe("Folder UUID"),
1478
+ folder_id: z.string().uuid().describe("Folder UUID, the folder being changed"),
1398
1479
  name: z.string().optional().describe("New name"),
1399
1480
  parent_folder_id: z.string().uuid().optional().describe("New parent folder UUID (owner-only)"),
1400
1481
  clear_parent: z.boolean().optional().describe("Move to workspace root (owner-only)"),
1401
- sharing_status: z.enum(["PRIVATE", "WORKSPACE"]).optional().describe("Explicit override (null clears to inherit)"),
1482
+ sharing: z.enum(["private", "workspace"]).optional().describe("Explicit override (null clears to inherit). The same word every other sharing tool takes, and the one a folder answers."),
1483
+ sharing_status: z.enum(["PRIVATE", "WORKSPACE"]).optional().describe("Legacy name for sharing. Kept forever; sharing wins when both are sent."),
1402
1484
  team_ids: z.array(z.string().uuid()).optional().describe("Desired team grants (replaces existing)"),
1403
- role: z.enum(["viewer", "editor"]).optional().describe("Role applied to NEW team grants (default viewer). Viewer grants are rejected on WORKSPACE folders as redundant."),
1485
+ role: z.enum(["viewer", "editor"]).optional().describe("Role applied to NEW team grants (default viewer). Viewer grants are rejected on a workspace folder as redundant."),
1404
1486
  },
1405
1487
  }, async (params) => {
1406
1488
  try {
@@ -1412,7 +1494,7 @@ export function createServer(apiKey) {
1412
1494
  return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
1413
1495
  }
1414
1496
  });
1415
- server.registerTool("lighthouse_delete_folder", {
1497
+ server.registerTool("lighthouse_documents_delete_folder", {
1416
1498
  title: "Delete Folder",
1417
1499
  description: "Restricted to the folder owner. Deletes the folder and its entire subtree only when you own every descendant folder and file. If any descendant is owned by another user, the request is rejected (CANNOT_DELETE_OTHERS_CONTENT_IN_FOLDER) and nothing is deleted. Editors on the folder cannot delete it. Irreversible: there is no recycle bin and the underlying stored files are permanently removed.",
1418
1500
  inputSchema: {
@@ -1427,9 +1509,10 @@ export function createServer(apiKey) {
1427
1509
  return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
1428
1510
  }
1429
1511
  });
1430
- server.registerTool("lighthouse_link_folder_to_record", {
1512
+ server.registerTool("lighthouse_documents_link_folder_to_record", {
1431
1513
  title: "Link Folder to Record",
1432
- description: "Link a folder to a CRM record, associating the whole folder with the record. The folder then appears on the record (in list_record_documents' `folders`, with its file_count); its individual files are not flattened into the record's direct `files` list.",
1514
+ description: "Link a folder to a CRM record, associating the whole folder with the record. The folder then appears on the record (in lighthouse_documents_list_for_record's `folders`); its individual files are not flattened into the record's direct `files` list. " +
1515
+ "Answers THE FOLDER itself, the same row lighthouse_documents_get_folder returns (owner, is_owner, my_role, sharing, shared_with_teams[] each { team_id, team_name, role, granted_at }, linked_records_count and linked_records[] as they are after the link), so there is no second call to make.",
1433
1516
  inputSchema: {
1434
1517
  folder_id: z.string().uuid().describe("Folder UUID"),
1435
1518
  record_type: z.string().describe("company, person, deal, or a custom object permalink"),
@@ -1445,9 +1528,9 @@ export function createServer(apiKey) {
1445
1528
  return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
1446
1529
  }
1447
1530
  });
1448
- server.registerTool("lighthouse_unlink_folder_from_record", {
1531
+ server.registerTool("lighthouse_documents_unlink_folder_from_record", {
1449
1532
  title: "Unlink Folder from Record",
1450
- description: "Remove a folder→record link. Files inside stop appearing on the record's Files tab.",
1533
+ description: "Remove a folder→record link. Files inside stop appearing on the record's Files tab. Answers THE FOLDER itself, the same row lighthouse_documents_get_folder returns, with its links as they are after the call.",
1451
1534
  inputSchema: {
1452
1535
  folder_id: z.string().uuid().describe("Folder UUID"),
1453
1536
  record_type: z.string().describe("company, person, deal, or a custom object permalink"),
@@ -1463,22 +1546,23 @@ export function createServer(apiKey) {
1463
1546
  return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
1464
1547
  }
1465
1548
  });
1466
- server.registerTool("lighthouse_upload_file", {
1549
+ server.registerTool("lighthouse_documents_upload_file", {
1467
1550
  title: "Upload File",
1468
1551
  description: "Reserve a file row and get a signed upload URL: " +
1469
1552
  "1) call this; 2) POST the file to upload_url as multipart/form-data, first append every key/value " +
1470
1553
  "from the returned upload_fields, then append the file last under the field name 'file'. " +
1471
1554
  "Do not set an Authorization header. Max 200MB, enforced by storage (oversized uploads are rejected). " +
1472
1555
  "The file is confirmed and sized automatically the first time you read it " +
1473
- "(lighthouse_get_file or a folder listing), no separate finalize step. " +
1556
+ "(lighthouse_documents_get_file or a folder listing), no separate finalize step. " +
1474
1557
  "The URL expires in 30 minutes.",
1475
1558
  inputSchema: {
1476
1559
  name: z.string().describe("File name (e.g. 'pitch-deck.pdf')"),
1477
1560
  content_type: z.string().optional().describe("MIME type (e.g. 'application/pdf')"),
1478
1561
  folder_id: z.string().uuid().optional().describe("Target folder UUID, omit to upload at workspace root"),
1479
- sharing_status: z.enum(["PRIVATE", "WORKSPACE"]).optional().describe("Required at root; defaults to inherit inside a folder"),
1480
- team_ids: z.array(z.string().uuid()).optional().describe("Team UUIDs to grant access when sharing_status=PRIVATE"),
1481
- role: z.enum(["viewer", "editor"]).optional().describe("Role applied to team_ids grants. Defaults to 'viewer'. Viewer grants on WORKSPACE files are rejected as redundant."),
1562
+ sharing: z.enum(["private", "workspace"]).optional().describe("Required at root; defaults to inherit inside a folder. The same word every other sharing tool takes, and the one a file answers."),
1563
+ sharing_status: z.enum(["PRIVATE", "WORKSPACE"]).optional().describe("Legacy name for sharing. Kept forever; sharing wins when both are sent."),
1564
+ team_ids: z.array(z.string().uuid()).optional().describe("Team UUIDs to grant access when sharing is private"),
1565
+ role: z.enum(["viewer", "editor"]).optional().describe("Role applied to team_ids grants. Defaults to 'viewer'. Viewer grants on a workspace file are rejected as redundant."),
1482
1566
  },
1483
1567
  }, async (params) => {
1484
1568
  try {
@@ -1489,9 +1573,9 @@ export function createServer(apiKey) {
1489
1573
  return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
1490
1574
  }
1491
1575
  });
1492
- server.registerTool("lighthouse_get_file", {
1576
+ server.registerTool("lighthouse_documents_get_file", {
1493
1577
  title: "Get File",
1494
- description: "Get file metadata. Response includes my_role ('owner' | 'editor' | 'viewer'), is_owner, owner_name, sharing_status, and shared_with_teams[] (each entry has { team_id, role }). Pass download_url=true to also receive a short-lived signed read URL (expires in 15 minutes).",
1578
+ description: "Get file metadata. Response includes my_role ('owner' | 'editor' | 'viewer'), is_owner, owner (the member who owns it: { id, first_name, last_name, full_name, avatar_url, email }), sharing ('private' or 'workspace'), linked_records_count, linked_records[] and shared_with_teams[] (each entry has { team_id, team_name, role, granted_at }). Pass download_url=true to also receive a short-lived signed read URL (expires in 15 minutes).",
1495
1579
  inputSchema: {
1496
1580
  file_id: z.string().uuid().describe("File UUID"),
1497
1581
  download_url: z.boolean().optional().describe("Include a short-lived signed download URL"),
@@ -1506,18 +1590,20 @@ export function createServer(apiKey) {
1506
1590
  return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
1507
1591
  }
1508
1592
  });
1509
- server.registerTool("lighthouse_update_file", {
1593
+ server.registerTool("lighthouse_documents_update_file", {
1510
1594
  title: "Update File",
1511
1595
  description: "Rename, move, or change sharing on a file. Editors can rename/move-between-folders and delete; only the owner can move to root, change sharing, or grant team access. " +
1512
- "team_ids replaces the current set when supplied.",
1596
+ "team_ids replaces the current set when supplied. " +
1597
+ "Returns the file row: owner, is_owner, my_role, sharing, shared_with_teams[] (each { team_id, team_name, role, granted_at }), linked_records_count, size_bytes, content_type, created_at, updated_at.",
1513
1598
  inputSchema: {
1514
- file_id: z.string().uuid().describe("File UUID"),
1599
+ file_id: z.string().uuid().describe("File UUID, the file being changed"),
1515
1600
  name: z.string().optional().describe("New name"),
1516
- folder_id: z.string().uuid().optional().describe("New parent folder UUID"),
1601
+ folder_id: z.string().uuid().optional().describe("New parent folder UUID, the folder the file is moved INTO (on lighthouse_documents_update_folder the same name means the folder being changed)"),
1517
1602
  clear_folder: z.boolean().optional().describe("Move to workspace root (owner-only)"),
1518
- sharing_status: z.enum(["PRIVATE", "WORKSPACE"]).optional().describe("Explicit override (null clears to inherit)"),
1603
+ sharing: z.enum(["private", "workspace"]).optional().describe("Explicit override (null clears to inherit). The same word every other sharing tool takes, and the one a file answers."),
1604
+ sharing_status: z.enum(["PRIVATE", "WORKSPACE"]).optional().describe("Legacy name for sharing. Kept forever; sharing wins when both are sent."),
1519
1605
  team_ids: z.array(z.string().uuid()).optional().describe("Desired team grants (replaces existing)"),
1520
- role: z.enum(["viewer", "editor"]).optional().describe("Role applied to NEW team grants (default viewer). Viewer grants are rejected on WORKSPACE files as redundant."),
1606
+ role: z.enum(["viewer", "editor"]).optional().describe("Role applied to NEW team grants (default viewer). Viewer grants are rejected on a workspace file as redundant."),
1521
1607
  },
1522
1608
  }, async (params) => {
1523
1609
  try {
@@ -1529,7 +1615,7 @@ export function createServer(apiKey) {
1529
1615
  return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
1530
1616
  }
1531
1617
  });
1532
- server.registerTool("lighthouse_delete_file", {
1618
+ server.registerTool("lighthouse_documents_delete_file", {
1533
1619
  title: "Delete File",
1534
1620
  description: "Owner or editor (file-level editor OR editor-on-parent-folder) can delete, provided they also have the files.delete workspace permission. Permanently deletes the file and purges the underlying object from storage. Irreversible.",
1535
1621
  inputSchema: {
@@ -1544,9 +1630,9 @@ export function createServer(apiKey) {
1544
1630
  return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
1545
1631
  }
1546
1632
  });
1547
- server.registerTool("lighthouse_link_file_to_record", {
1633
+ server.registerTool("lighthouse_documents_link_file_to_record", {
1548
1634
  title: "Link File to Record",
1549
- description: "Make a file appear on a CRM record's Files tab.",
1635
+ description: "Make a file appear on a CRM record's Files tab. Answers THE FILE itself, the same row lighthouse_documents_get_file returns, with its links as they are after the call.",
1550
1636
  inputSchema: {
1551
1637
  file_id: z.string().uuid().describe("File UUID"),
1552
1638
  record_type: z.string().describe("company, person, deal, or a custom object permalink"),
@@ -1562,9 +1648,9 @@ export function createServer(apiKey) {
1562
1648
  return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
1563
1649
  }
1564
1650
  });
1565
- server.registerTool("lighthouse_unlink_file_from_record", {
1651
+ server.registerTool("lighthouse_documents_unlink_file_from_record", {
1566
1652
  title: "Unlink File from Record",
1567
- description: "Remove a file→record link.",
1653
+ description: "Remove a file→record link. Answers THE FILE itself, the same row lighthouse_documents_get_file returns, with its links as they are after the call.",
1568
1654
  inputSchema: {
1569
1655
  file_id: z.string().uuid().describe("File UUID"),
1570
1656
  record_type: z.string().describe("company, person, deal, or a custom object permalink"),
@@ -1580,10 +1666,10 @@ export function createServer(apiKey) {
1580
1666
  return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
1581
1667
  }
1582
1668
  });
1583
- server.registerTool("lighthouse_list_record_documents", {
1584
- title: "List Record Documents",
1585
- description: "List folders and files linked to a CRM record. Returns { folders, files }. `files` are files linked DIRECTLY to the record; files that live inside a linked folder are NOT flattened into `files`. A linked folder appears in `folders` with its own file_count, so to read a folder's contents list that folder by its id. " +
1586
- "Each row includes my_role ('owner' | 'editor' | 'viewer'), is_owner, owner_name, and shared_with_teams[] (each entry has { team_id, role }). " +
1669
+ server.registerTool("lighthouse_documents_list_for_record", {
1670
+ title: "List a Record's Documents",
1671
+ description: "List folders and files linked to a CRM record. Returns { folders, files }. `files` are files linked DIRECTLY to the record; files that live inside a linked folder are NOT flattened into `files`. To read a linked folder's contents, list that folder by its id with lighthouse_documents_list. " +
1672
+ "Each row includes my_role ('owner' | 'editor' | 'viewer'), is_owner, owner (the member who owns it: { id, first_name, last_name, full_name, avatar_url, email }), sharing ('private' or 'workspace'), linked_records_count, linked_at, and shared_with_teams[] (each entry has { team_id, team_name, role, granted_at }). " +
1587
1673
  "Cursor-based pagination via meta.folders_next_cursor / meta.files_next_cursor; pass them back as folders_cursor / files_cursor to fetch the next page.",
1588
1674
  inputSchema: {
1589
1675
  record_type: z.string().describe("company, person, deal, or a custom object permalink"),
@@ -1613,7 +1699,8 @@ export function createServer(apiKey) {
1613
1699
  title: "List CRM Views",
1614
1700
  description: "Get all saved CRM views, or list views scoped to a specific list. " +
1615
1701
  "Supports built-in types and custom objects. " +
1616
- "Views store filter/sort configurations that can be loaded in search_records.",
1702
+ "Views store filter/sort configurations that can be loaded in search_records. " +
1703
+ "Each row carries record_type, owner (the member who created it) and is_owner (true when that member is the API key's user), so 'my views' and 'views shared with me' can be told apart without a second call.",
1617
1704
  inputSchema: {
1618
1705
  record_type: z.string().optional().describe("Filter by record type: 'company', 'person', 'deal', or a custom object permalink (e.g. 'lp', 'project')"),
1619
1706
  list_id: z.string().uuid().optional().describe("Filter views by list ID. When provided, returns only views scoped to that list."),
@@ -1632,8 +1719,9 @@ export function createServer(apiKey) {
1632
1719
  description: "Update a saved CRM view or list view's name, filters, sort, or layout. " +
1633
1720
  "Automatically detects whether it's a CRM view or list view. " +
1634
1721
  "To change sharing settings, use lighthouse_crm_update_view_sharing instead. " +
1635
- "Uses CRM filter format, call lighthouse_crm_get_attributes first if modifying filters. " +
1636
- "A semantic_search condition in the new filters must carry its vector (keep the one the view already has, from lighthouse_crm_get_view, or one copied from the app): this API never builds an embedding from text.",
1722
+ "Uses CRM filter format, call lighthouse_crm_list_attributes first if modifying filters. " +
1723
+ "A semantic_search condition in the new filters must carry its vector (keep the one the view already has, from lighthouse_crm_get_view, or one copied from the app): this API never builds an embedding from text. " +
1724
+ "Every key is optional and a key you leave out keeps the value the view already has, the board settings included, so changing a filter never reopens the columns someone collapsed.",
1637
1725
  inputSchema: {
1638
1726
  id: z.string().uuid().describe("View ID"),
1639
1727
  name: z.string().optional(),
@@ -1641,6 +1729,16 @@ export function createServer(apiKey) {
1641
1729
  sort: z.array(z.record(z.unknown())).optional().describe(CRM_SORT_DESCRIPTION),
1642
1730
  view_type: z.enum(["table", "kanban"]).optional(),
1643
1731
  grouped_by: z.string().optional().describe("Field key to group by (required for kanban views)"),
1732
+ hidden_kanban_groups: z
1733
+ .array(z.unknown())
1734
+ .nullable()
1735
+ .optional()
1736
+ .describe("Kanban views: the values whose column is collapsed. An array of column values, or null to show every column again. Read the current ones with lighthouse_crm_get_view."),
1737
+ kanban_summary: z
1738
+ .record(z.unknown())
1739
+ .nullable()
1740
+ .optional()
1741
+ .describe('Kanban views: the number shown at the top of each column, as {"field":"<key>","is_custom":true|false,"stat":"sum"|"avg"|"min"|"max"}, or null to remove it. field is the field key it sums up and is_custom says whether that is a custom field. A view answers this exactly as it takes it.'),
1644
1742
  },
1645
1743
  }, async (params) => {
1646
1744
  try {
@@ -1654,7 +1752,7 @@ export function createServer(apiKey) {
1654
1752
  });
1655
1753
  server.registerTool("lighthouse_crm_get_view", {
1656
1754
  title: "Get CRM View",
1657
- description: "Get a single saved CRM view or list view by ID. Automatically detects the view type. The filters come back exactly as saved, a semantic_search condition with its vector included: reuse such a condition verbatim in lighthouse_crm_search_records or lighthouse_crm_get_list_records.",
1755
+ description: "Get a single saved CRM view or list view by ID. Automatically detects the view type. The filters come back as saved, a semantic_search condition with its vector included: reuse such a condition verbatim in lighthouse_crm_search_records or lighthouse_crm_list_list_records. Conditions on lists you cannot open are left out, and so are conditions on custom fields that belong to such a list. The row carries record_type, owner and is_owner (true when the API key's user owns the view). A kanban view's board settings come with it: hidden_kanban_groups (the values whose column is collapsed, always an array) and kanban_summary (the number at the top of each column, or null), both writable through lighthouse_crm_update_view exactly as they are read.",
1658
1756
  inputSchema: {
1659
1757
  id: z.string().uuid().describe("View ID"),
1660
1758
  },
@@ -1684,7 +1782,8 @@ export function createServer(apiKey) {
1684
1782
  });
1685
1783
  server.registerTool("lighthouse_workspace_me", {
1686
1784
  title: "Get Current User",
1687
- description: "Get the current authenticated user's profile (name, email, role, etc.).",
1785
+ description: "Get the profile of the user this key acts as: id, name, email, avatar, phone_number, status, created_at, workspace_role, permissions and `teams` ([{ id, name, role, joined_at }], [] when they are on none). " +
1786
+ "It is the same object lighthouse_workspace_get_user answers for anyone else, so read it once to learn who you are acting as and what that role may do, rather than calling the user tool on yourself.",
1688
1787
  inputSchema: {},
1689
1788
  }, async () => {
1690
1789
  try {
@@ -1761,21 +1860,26 @@ export function createServer(apiKey) {
1761
1860
  return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
1762
1861
  }
1763
1862
  });
1764
- server.registerTool("lighthouse_discovery_get_attributes", {
1765
- title: "Get Discovery Data Fields",
1863
+ server.registerTool("lighthouse_discovery_list_attributes", {
1864
+ title: "List Discovery Data Fields",
1766
1865
  description: "Get a lightweight catalog of the DATA FIELDS discovery results carry for companies or people: each field's permalink, label, data_type, links_to, read_only, and has_options. " +
1767
1866
  "These are OUTPUT/data fields, NOT filter keys. To FILTER a search you MUST use the prefixed company_* / person_* keys listed in the `filters` parameter of the discovery search tools. " +
1768
- "To keep the response small it does NOT include option values: when a field has has_options=true and you need its allowed values, call lighthouse_discovery_list_options with that permalink (or the dedicated lighthouse_discovery_get_industries / lighthouse_discovery_get_locations / lighthouse_discovery_get_funding_types / lighthouse_discovery_get_tags tools). " +
1867
+ "To keep the response small it does NOT include option values: when a field has has_options=true and you need its allowed values, call lighthouse_discovery_list_options with that permalink (or the dedicated lighthouse_discovery_list_industries / lighthouse_discovery_list_locations / lighthouse_discovery_list_funding_types / lighthouse_discovery_list_tags tools). " +
1769
1868
  "Calling this is optional and NOT required before searching. These are Lighthouse's global database fields, not your CRM fields.",
1770
1869
  inputSchema: {
1870
+ record_type: z
1871
+ .enum(["company", "person"])
1872
+ .optional()
1873
+ .describe("Record type: 'company' or 'person'. If omitted, returns both as { company: [...], person: [...] }."),
1771
1874
  type: z
1772
1875
  .enum(["company", "person"])
1773
1876
  .optional()
1774
- .describe("Entity type: 'company' or 'person'. If omitted, returns both as { company: [...], person: [...] }."),
1877
+ .describe("Legacy name for record_type. Kept forever; record_type wins when both are sent."),
1775
1878
  },
1776
1879
  }, async (params) => {
1777
1880
  try {
1778
- const res = await api.post("/mcp/database/attributes", params);
1881
+ const { record_type, type, ...rest } = params;
1882
+ const res = await api.post("/mcp/database/attributes", { ...rest, type: record_type ?? type });
1779
1883
  return text(res);
1780
1884
  }
1781
1885
  catch (e) {
@@ -1785,13 +1889,14 @@ export function createServer(apiKey) {
1785
1889
  server.registerTool("lighthouse_discovery_list_options", {
1786
1890
  title: "List Discovery Field Options",
1787
1891
  description: "Get the allowed values (value, label, color) for a single discovery select / multi_select field. " +
1788
- "Call this when lighthouse_discovery_get_attributes shows has_options=true for a field and you need its valid values. " +
1892
+ "Call this when lighthouse_discovery_list_attributes shows has_options=true for a field and you need its valid values. " +
1789
1893
  "Results are paginated (max 100 per page): use `search` to narrow by label, and `limit` / `offset` to page through a field with many values (e.g. locations). " +
1790
- "For the large shared taxonomies you can also use the dedicated tools: get_industries, get_locations, get_funding_types, get_tags.",
1894
+ "For the large shared taxonomies you can also use the dedicated tools: lighthouse_discovery_list_industries, lighthouse_discovery_list_locations, lighthouse_discovery_list_funding_types, lighthouse_discovery_list_tags.",
1791
1895
  inputSchema: {
1792
1896
  record_type: z.enum(["company", "person"]).describe("Entity type the field belongs to"),
1793
- field_permalink: z.string().describe("Field permalink (from lighthouse_discovery_get_attributes)"),
1897
+ field_permalink: z.string().describe("Field permalink (from lighthouse_discovery_list_attributes)"),
1794
1898
  search: z.string().optional().describe("Filter options whose label contains this text (case-insensitive)"),
1899
+ query: z.string().optional().describe("Same as search. Either name works; search wins when both are sent."),
1795
1900
  limit: z.number().optional().describe("Max options to return (default 100, max 100)"),
1796
1901
  offset: z.number().optional().describe("Number of options to skip for pagination (default 0)"),
1797
1902
  },
@@ -1809,12 +1914,13 @@ export function createServer(apiKey) {
1809
1914
  description: "Search Lighthouse's global company database to DISCOVER new companies. " +
1810
1915
  "This searches Lighthouse's enriched database (millions of companies), NOT your CRM. " +
1811
1916
  "WORKFLOW: 1) Build filters using ONLY the company_* filter keys listed in the `filters` parameter below. " +
1812
- "Do NOT call lighthouse_discovery_get_attributes for filter keys. It returns the data fields each result carries (e.g. categories, headquarter_country), which are NOT valid filter keys; if used to filter they are dropped and reported in the response's `ignored_filter_fields`. " +
1813
- "2) For category filters (company_categories, company_sdg_impact), call lighthouse_discovery_get_industries to get valid values. " +
1814
- "3) For location filters (company_hq_country, company_hq_region, and any other *_country/*_region key), call lighthouse_discovery_get_locations to get country/region codes. " +
1917
+ "Do NOT call lighthouse_discovery_list_attributes for filter keys. It returns the data fields each result carries (e.g. categories, headquarter_country), which are NOT valid filter keys; if used to filter they are dropped and reported in the response's `ignored_filter_fields`. " +
1918
+ "2) For category filters (company_categories, company_sdg_impact), call lighthouse_discovery_list_industries to get valid values. " +
1919
+ "3) For location filters (company_hq_country, company_hq_region, and any other *_country/*_region key), call lighthouse_discovery_list_locations to get country/region codes. " +
1815
1920
  "4) For investor/past-company/education filters, call lighthouse_discovery_search_entities (record_type 'investor', 'company', or 'school') to resolve names to ids. " +
1816
1921
  "The COMPLETE, authoritative list of company_* filter keys (every field with its data type and where its values come from) is at the END of the `filters` parameter description below. Read it and use those keys exactly; do not anchor on a subset, invent variants (no _usd suffix), or abbreviate (hq vs headquarter). " +
1817
- "The response's `meta` includes `total` (the match count, e.g. \"247\", or \"1k+\" when there are 1000+) and `ignored_filter_fields` (any filter key that was NOT recognized, each with a did_you_mean suggestion when a close match can be computed). Check `ignored_filter_fields` to confirm every filter was actually applied.",
1922
+ "The response's `meta` includes `total` (the match count, e.g. \"247\", or \"1k+\" when there are 1000+) and `ignored_filter_fields` (any filter key that was NOT recognized, each with a did_you_mean suggestion when a close match can be computed). Check `ignored_filter_fields` to confirm every filter was actually applied." +
1923
+ DISCOVERY_CRM_ID_NOTE,
1818
1924
  inputSchema: {
1819
1925
  filters: z
1820
1926
  .record(z.unknown())
@@ -1838,13 +1944,14 @@ export function createServer(apiKey) {
1838
1944
  description: "Search Lighthouse's global people database to DISCOVER new people. " +
1839
1945
  "This searches Lighthouse's enriched database, NOT your CRM. " +
1840
1946
  "WORKFLOW: 1) Build filters using ONLY the person_* filter keys listed in the `filters` parameter below. " +
1841
- "Do NOT call lighthouse_discovery_get_attributes for filter keys. It returns the data fields each result carries (e.g. country, categories), which are NOT valid filter keys; if used to filter they are dropped and reported in the response's `ignored_filter_fields`. " +
1842
- "2) For category filters (person_current_categories, person_past_categories), call lighthouse_discovery_get_industries to get valid values. " +
1843
- "3) For location filters (person_country, person_region, and any other *_country/*_region key), call lighthouse_discovery_get_locations to get country/region codes. " +
1947
+ "Do NOT call lighthouse_discovery_list_attributes for filter keys. It returns the data fields each result carries (e.g. country, categories), which are NOT valid filter keys; if used to filter they are dropped and reported in the response's `ignored_filter_fields`. " +
1948
+ "2) For category filters (person_current_categories, person_past_categories), call lighthouse_discovery_list_industries to get valid values. " +
1949
+ "3) For location filters (person_country, person_region, and any other *_country/*_region key), call lighthouse_discovery_list_locations to get country/region codes. " +
1844
1950
  "4) For past-company/education filters, call lighthouse_discovery_search_entities (record_type 'company' or 'school') to resolve names to ids. " +
1845
1951
  "The COMPLETE, authoritative list of person_* filter keys (every field with its data type and where its values come from) is at the END of the `filters` parameter description below. Read it and use those keys exactly; do not anchor on a subset, invent variants, or abbreviate. " +
1846
1952
  "The response's `meta` includes `total` (the match count, e.g. \"247\", or \"1k+\" when there are 1000+) and `ignored_filter_fields` (any filter key that was NOT recognized, each with a did_you_mean suggestion when a close match can be computed). Check `ignored_filter_fields` to confirm every filter was actually applied. " +
1847
- "People results are returned by founder relevance; custom sorting is NOT supported for people, so there is no sort parameter.",
1953
+ "People results are returned by founder relevance; custom sorting is NOT supported for people, so there is no sort parameter." +
1954
+ DISCOVERY_CRM_ID_NOTE,
1848
1955
  inputSchema: {
1849
1956
  filters: z
1850
1957
  .record(z.unknown())
@@ -1870,7 +1977,8 @@ export function createServer(apiKey) {
1870
1977
  "PREFER lighthouse_discovery_search_companies (no `query`) when the user only needs exact attribute filters (a specific country, funding stage, headcount range) with no conceptual component. " +
1871
1978
  "If the user asks for something this cannot satisfy (an exact field the database does not expose, a guarantee of completeness, or ordering by a specific metric), tell them that plainly and fall back to lighthouse_discovery_search_companies with filters rather than guessing. " +
1872
1979
  "The response's `meta` includes `total` (the match count, e.g. \"247\", or \"1k+\" when there are 1000+) and `ignored_filter_fields` (any filter key that was NOT recognized, each with a did_you_mean suggestion when a close match can be computed). Check `ignored_filter_fields` to confirm every filter was actually applied. " +
1873
- "`query` is required; everything else is optional.",
1980
+ "`query` is required; everything else is optional." +
1981
+ DISCOVERY_CRM_ID_NOTE,
1874
1982
  inputSchema: {
1875
1983
  query: z.string().min(1).describe("Plain-language description of the ideal company to match by meaning. Required."),
1876
1984
  filters: z
@@ -1903,7 +2011,8 @@ export function createServer(apiKey) {
1903
2011
  "PREFER lighthouse_discovery_search_people (no `query`) when the user only needs exact attribute filters with no conceptual component. " +
1904
2012
  "If the user asks for something this cannot satisfy (an exact field the database does not expose, a guarantee of completeness, or ordering by a specific metric), tell them that plainly and fall back to lighthouse_discovery_search_people with filters rather than guessing. " +
1905
2013
  "The response's `meta` includes `total` (the match count, e.g. \"247\", or \"1k+\" when there are 1000+) and `ignored_filter_fields` (any filter key that was NOT recognized, each with a did_you_mean suggestion when a close match can be computed). Check `ignored_filter_fields` to confirm every filter was actually applied. " +
1906
- "`query` is required; everything else is optional.",
2014
+ "`query` is required; everything else is optional." +
2015
+ DISCOVERY_CRM_ID_NOTE,
1907
2016
  inputSchema: {
1908
2017
  query: z.string().min(1).describe("Plain-language description of the ideal person to match by meaning. Required."),
1909
2018
  filters: z
@@ -1935,7 +2044,8 @@ export function createServer(apiKey) {
1935
2044
  "Each returned profile is condensed (the long growth and workforce history and the activity-signal " +
1936
2045
  "feed are omitted, and the associated-people list is capped) to keep the response compact; for a " +
1937
2046
  "single company's full profile call lighthouse_discovery_get_company. " +
1938
- "Consumes API entity credits for each new company returned.",
2047
+ "Consumes API entity credits for each new company returned." +
2048
+ DISCOVERY_CRM_ID_NOTE,
1939
2049
  inputSchema: {
1940
2050
  domains: z
1941
2051
  .array(z.string())
@@ -1957,7 +2067,8 @@ export function createServer(apiKey) {
1957
2067
  "Each returned profile is condensed (the long growth and workforce history and the activity-signal " +
1958
2068
  "feed are omitted, and the associated-people list is capped) to keep the response compact; for a " +
1959
2069
  "single company's full profile call lighthouse_discovery_get_company. " +
1960
- "Consumes API entity credits for each new company returned.",
2070
+ "Consumes API entity credits for each new company returned." +
2071
+ DISCOVERY_CRM_ID_NOTE,
1961
2072
  inputSchema: {
1962
2073
  linkedin_urls: z
1963
2074
  .array(z.string())
@@ -1979,7 +2090,8 @@ export function createServer(apiKey) {
1979
2090
  "Each returned profile is condensed (the activity-signal feed is omitted and the associated-companies " +
1980
2091
  "and education lists are capped) to keep the response compact; for a single person's full profile call " +
1981
2092
  "lighthouse_discovery_get_person. " +
1982
- "Consumes API entity credits for each new person returned.",
2093
+ "Consumes API entity credits for each new person returned." +
2094
+ DISCOVERY_CRM_ID_NOTE,
1983
2095
  inputSchema: {
1984
2096
  linkedin_urls: z
1985
2097
  .array(z.string())
@@ -1994,12 +2106,12 @@ export function createServer(apiKey) {
1994
2106
  return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
1995
2107
  }
1996
2108
  });
1997
- server.registerTool("lighthouse_discovery_get_saved_searches", {
2109
+ server.registerTool("lighthouse_discovery_list_saved_searches", {
1998
2110
  title: "List Saved Searches",
1999
2111
  description: "List all saved discovery searches in the workspace. " +
2000
2112
  "Returns saved searches you own and workspace-shared searches. " +
2001
2113
  "Each search includes its filters and type (company or person). " +
2002
- "Use the search ID with lighthouse_discovery_get_search_results to run a saved search.",
2114
+ "Use the search ID with lighthouse_discovery_list_search_results to run a saved search.",
2003
2115
  inputSchema: {},
2004
2116
  }, async () => {
2005
2117
  try {
@@ -2010,14 +2122,15 @@ export function createServer(apiKey) {
2010
2122
  return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
2011
2123
  }
2012
2124
  });
2013
- server.registerTool("lighthouse_discovery_get_search_results", {
2014
- title: "Get Saved Search Results",
2125
+ server.registerTool("lighthouse_discovery_list_search_results", {
2126
+ title: "List Saved Search Results",
2015
2127
  description: "Run a saved discovery search and get its results. " +
2016
2128
  "The search's saved filters are applied automatically. " +
2017
2129
  "Optionally filter by signal creation date range. " +
2018
- "Consumes API entity credits for new entities returned.",
2130
+ "Consumes API entity credits for new entities returned." +
2131
+ DISCOVERY_CRM_ID_NOTE,
2019
2132
  inputSchema: {
2020
- search_id: z.string().uuid().describe("Saved search ID (from lighthouse_discovery_get_saved_searches)"),
2133
+ search_id: z.string().uuid().describe("Saved search ID (from lighthouse_discovery_list_saved_searches)"),
2021
2134
  signal_created_after: z.string().optional().describe("Only include results with signals created after this ISO timestamp"),
2022
2135
  signal_created_before: z.string().optional().describe("Only include results with signals created before this ISO timestamp"),
2023
2136
  skip: z.number().min(0).default(0).describe("Number of results to skip"),
@@ -2032,8 +2145,8 @@ export function createServer(apiKey) {
2032
2145
  return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
2033
2146
  }
2034
2147
  });
2035
- server.registerTool("lighthouse_discovery_get_locations", {
2036
- title: "Get Location Options",
2148
+ server.registerTool("lighthouse_discovery_list_locations", {
2149
+ title: "List Location Options",
2037
2150
  description: "Get available location options (countries and regions) for discovery filters. " +
2038
2151
  "ALWAYS call this to get valid country/region values before filtering by location fields " +
2039
2152
  "(company_hq_country, company_hq_region, person_country, person_region, company_investor_country, etc.). " +
@@ -2050,8 +2163,8 @@ export function createServer(apiKey) {
2050
2163
  return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
2051
2164
  }
2052
2165
  });
2053
- server.registerTool("lighthouse_discovery_get_industries", {
2054
- title: "Get Industry & Category Options",
2166
+ server.registerTool("lighthouse_discovery_list_industries", {
2167
+ title: "List Industry & Category Options",
2055
2168
  description: "Get available industry and category options for discovery filters. " +
2056
2169
  "ALWAYS call this to get valid category values before filtering by company_categories, company_sdg_impact, " +
2057
2170
  "person_current_categories, person_past_categories, person_current_sdg_impact, or person_past_sdg_impact. " +
@@ -2068,8 +2181,8 @@ export function createServer(apiKey) {
2068
2181
  return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
2069
2182
  }
2070
2183
  });
2071
- server.registerTool("lighthouse_discovery_get_tags", {
2072
- title: "Get Tag Options",
2184
+ server.registerTool("lighthouse_discovery_list_tags", {
2185
+ title: "List Tag Options",
2073
2186
  description: "Get the tag options for discovery filters. " +
2074
2187
  "ALWAYS call this to get valid tag values before filtering by company_tags or person_tags. " +
2075
2188
  "Returns {company_tags: [{value, name}], person_tags: [{value, name}]}. " +
@@ -2084,13 +2197,13 @@ export function createServer(apiKey) {
2084
2197
  return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
2085
2198
  }
2086
2199
  });
2087
- server.registerTool("lighthouse_discovery_get_funding_types", {
2088
- title: "Get Funding Type Options",
2200
+ server.registerTool("lighthouse_discovery_list_funding_types", {
2201
+ title: "List Funding Type Options",
2089
2202
  description: "Get the funding round types available for the funding discovery filters " +
2090
2203
  "(company_last_funding_type, person_company_last_funding_type). " +
2091
- "Returns an array of {name, label} ordered by stage. " +
2092
- "Use each option's 'name' (e.g. 'seed', 'series_a') as the filter value with " +
2093
- "contains_any / not_contains_any operators. " +
2204
+ "Returns an array of { value, label, name } ordered by stage. " +
2205
+ "Use each option's 'value' (e.g. 'seed', 'series_a') as the filter value with " +
2206
+ "contains_any / not_contains_any operators; 'name' carries the same string and is kept for compatibility. " +
2094
2207
  "The filters also accept the special value 'no_funding' to match companies with no recorded funding round.",
2095
2208
  inputSchema: {},
2096
2209
  }, async () => {
@@ -2204,7 +2317,11 @@ export function createServer(apiKey) {
2204
2317
  "Always returns data for an entity that exists, including companies not shown in discovery result " +
2205
2318
  "lists, so there is no dead-end. " +
2206
2319
  "Obtain the id from lighthouse_discovery_search_entities (record_type 'company') first. " +
2207
- "This accepts only a company id (not a person, investor, or school id), and does not search by name.",
2320
+ "This accepts only a company id (not a person, investor, or school id), and does not search by name. " +
2321
+ "Each investor on a funding round carries is_lead: true when that investor led the round, false when " +
2322
+ "it did not, never null. is_lead_inv is the same fact under the older name and can be null where " +
2323
+ "is_lead says false, so read is_lead." +
2324
+ DISCOVERY_CRM_ID_NOTE,
2208
2325
  inputSchema: {
2209
2326
  id: z.string().describe("The company id (UUID) returned by lighthouse_discovery_search_entities"),
2210
2327
  },
@@ -2226,7 +2343,9 @@ export function createServer(apiKey) {
2226
2343
  "Always returns data for an entity that exists, including people not shown in discovery result lists, " +
2227
2344
  "so there is no dead-end. " +
2228
2345
  "Obtain the id from lighthouse_discovery_search_entities (record_type 'person') first. " +
2229
- "This accepts only a person id, and does not search by name.",
2346
+ "This accepts only a person id, and does not search by name. " +
2347
+ "Each entry of associated_companies and education carries id, the company's or school's own id, which is what lighthouse_discovery_get_company takes. The same value is also under org_id, the name those two arrays have always used and which is kept for compatibility." +
2348
+ DISCOVERY_CRM_ID_NOTE,
2230
2349
  inputSchema: {
2231
2350
  id: z.string().describe("The person id (UUID) returned by lighthouse_discovery_search_entities"),
2232
2351
  },
@@ -2302,7 +2421,8 @@ export function createServer(apiKey) {
2302
2421
  "It picks the single top match, so when the name is ambiguous and you want to choose among candidates, " +
2303
2422
  "use lighthouse_discovery_search_entities instead and pass the chosen id to get_company / get_person. " +
2304
2423
  "Do NOT build a profile from a search/lookup result row (those are thin identity rows): use this tool, " +
2305
- "or get_company / get_person with the id.",
2424
+ "or get_company / get_person with the id." +
2425
+ DISCOVERY_CRM_ID_NOTE,
2306
2426
  inputSchema: {
2307
2427
  record_type: z
2308
2428
  .enum(["company", "person", "investor", "school"])
@@ -2319,9 +2439,10 @@ export function createServer(apiKey) {
2319
2439
  }
2320
2440
  });
2321
2441
  const WIDGET_CONFIG_DESCRIPTION = "Widget config object. Structure depends on report_type: " +
2322
- "STANDARD (bar/line/pie/table/kpi): {data_source:'companies'|'deals'|'people' (ALWAYS plural: the in-app report editor only recognizes the plural form) or a custom object permalink (e.g. 'lp'; then ALSO set custom_object_permalink to the same permalink; it is required), report_type:'standard' (always set it explicitly), " +
2442
+ "NAMES: you can send record_type ('company' | 'person' | 'deal' | a custom object permalink) instead of data_source / entity_type, is_custom instead of is_custom_field, and an aggregation in any case. Each is translated into the stored spelling the keys below describe. A widget answers BOTH: record_type beside data_source and entity_type, is_custom beside is_custom_field (aggregation is answered uppercase). Read record_type when you want one word whatever the widget was created with; the stored keys never move, and the config a widget answers can be sent back as it is. " +
2443
+ "STANDARD (bar/line/pie/table/kpi): {data_source:'companies'|'deals'|'people' (the plural form is what the app stores; the singular is accepted too, and whichever you send is echoed back unchanged, with record_type beside it) or a custom object permalink (e.g. 'lp'; then ALSO set custom_object_permalink to the same permalink; it is required), report_type:'standard' (always set it explicitly), " +
2323
2444
  "dimension:{field:'<field_key>', is_custom_field:boolean, time_grouping?:'day'|'week'|'month'|'quarter'|'year'}. REQUIRED for EVERY standard widget INCLUDING kpi (a kpi groups its measure by this dimension); omitting it fails with DIMENSION_REQUIRED. For a line chart the dimension field must be a date/datetime type. " +
2324
- "measure:{aggregation:'COUNT'|'SUM'|'AVG'|'MIN'|'MAX'|'COUNT_DISTINCT', field:'*'|'<field_key>', is_custom_field:boolean}. Aggregations are UPPERCASE. SUM/AVG/MIN/MAX require a numeric field (number/currency/rating/percentage); COUNT always counts records, so pair it with field:'*' only. Do NOT pair SUM/AVG with a multi-value (multi_select/multi_user/multi_record) dimension or segment (the value would be over-counted); use COUNT there instead. " +
2445
+ "measure:{aggregation:'COUNT'|'SUM'|'AVG'|'MIN'|'MAX'|'COUNT_DISTINCT', field:'*'|'<field_key>', is_custom_field:boolean}. Aggregations are stored UPPERCASE and accepted in any case. SUM/AVG/MIN/MAX require a numeric field (number/currency/rating/percentage); COUNT always counts records, so pair it with field:'*' only. Do NOT pair SUM/AVG with a multi-value (multi_select/multi_user/multi_record) dimension or segment (the value would be over-counted); use COUNT there instead. " +
2325
2446
  "segment_by?:{field:'<field_key>', is_custom_field:boolean} splits each bar into stacked series. It ONLY applies to a bar widget; it is ignored for line/pie/table/kpi. " +
2326
2447
  "filter?:{operator:'AND'|'OR', conditions:[...], groups:[...]}, " +
2327
2448
  "date_range?:{type:'relative', relative_period:'all_time'|'last_7_days'|'last_30_days'|'last_90_days'|'this_month'|'last_month'|'this_quarter'|'last_quarter'|'this_year'|'last_year'} " +
@@ -2331,12 +2452,12 @@ export function createServer(apiKey) {
2331
2452
  "(first_email_date, last_email_date, first_linkedin_message_date, last_linkedin_message_date, first_meeting_date, last_meeting_date, first_interaction_date, last_interaction_date) " +
2332
2453
  "or by first_interaction_type/last_interaction_type, all with is_custom_field:false; and you can measure lifetime interaction totals with " +
2333
2454
  "measure.field:'__ix_count_all__'|'__ix_count_email__'|'__ix_count_linkedin__'|'__ix_count_meeting__' (is_custom_field:false; the aggregation is ignored). " +
2334
- "FUNNEL: {report_type:'funnel', entity_type:'company'|'deal'|'person' (custom objects are NOT supported for funnel), data_source:'companies'|'deals'|'people' (plural form of entity_type; ALWAYS include it or the widget opens with the wrong data source in the in-app editor), stage_field:'<status field_key>', stage_field_is_custom:boolean (true when stage_field is a custom status field), " +
2455
+ "FUNNEL: {report_type:'funnel', entity_type:'company'|'deal'|'person' (custom objects are NOT supported for funnel), data_source:'companies'|'deals'|'people' (plural form of entity_type; ALWAYS include it or the widget opens with the wrong data source in the in-app editor; sending record_type instead fills both), stage_field:'<status field_key>', stage_field_is_custom:boolean (true when stage_field is a custom status field), " +
2335
2456
  "included_stages?:['stage1','stage2'], date_range?:{...}, filter?:{...} (same format and field support as the standard filter above, including relation fields like list_entries and custom fields; it restricts which records the funnel counts)}. " +
2336
- "HISTORICAL: {report_type:'stage_changes', entity_type:'company'|'deal'|'person' (custom objects are NOT supported for historical), data_source:'companies'|'deals'|'people' (plural form of entity_type; ALWAYS include it), " +
2457
+ "HISTORICAL: {report_type:'stage_changes', entity_type:'company'|'deal'|'person' (custom objects are NOT supported for historical), data_source:'companies'|'deals'|'people' (plural form of entity_type; ALWAYS include it; sending record_type instead fills both), " +
2337
2458
  "field_permalink:'<status field_key>', time_grouping?:'day'|'week'|'month'|'quarter'|'year', included_stages?:['stage1','stage2'], date_range?:{...}}. " +
2338
- "included_stages values must be the option VALUES from lighthouse_crm_get_attributes (not display labels). " +
2339
- "ALWAYS call lighthouse_crm_get_attributes first to discover valid field keys for dimension, measure, and stage_field.";
2459
+ "included_stages values must be the option VALUES from lighthouse_crm_list_attributes (not display labels). " +
2460
+ "ALWAYS call lighthouse_crm_list_attributes first to discover valid field keys for dimension, measure, and stage_field.";
2340
2461
  server.registerTool("lighthouse_reports_list_dashboards", {
2341
2462
  title: "List Dashboards",
2342
2463
  description: "List all report dashboards accessible to the current user. " +
@@ -2398,12 +2519,18 @@ export function createServer(apiKey) {
2398
2519
  server.registerTool("lighthouse_reports_update_dashboard", {
2399
2520
  title: "Update Dashboard",
2400
2521
  description: "Update a dashboard's name, description, or sharing. Only the dashboard owner can update. " +
2401
- "Returns the full dashboard object (same format as get dashboard).",
2522
+ "Returns the full dashboard object (same format as get dashboard), plus shared_along and audience_cannot_open. " +
2523
+ "Pass share_along: true ONLY when the user asked to share the lists as well; a list is shared along only when " +
2524
+ "the user's role can share lists. The response always carries shared_along and audience_cannot_open.",
2402
2525
  inputSchema: {
2403
2526
  id: z.string().uuid().describe("Dashboard ID"),
2404
2527
  name: z.string().optional().describe("New dashboard name"),
2405
2528
  description: z.string().optional().describe("New description"),
2406
2529
  sharing: z.enum(["private", "workspace"]).optional().describe("New visibility setting"),
2530
+ share_along: z
2531
+ .boolean()
2532
+ .optional()
2533
+ .describe("Also share the lists you own that this dashboard's widgets filter on with the same people. Nothing is shared along unless you pass true."),
2407
2534
  },
2408
2535
  }, async (params) => {
2409
2536
  try {
@@ -2435,11 +2562,12 @@ export function createServer(apiKey) {
2435
2562
  description: "Get all widgets for a dashboard. " +
2436
2563
  "Each widget includes: id, name, widget_type, config, created_at, updated_at.",
2437
2564
  inputSchema: {
2438
- id: z.string().uuid().describe("Dashboard ID"),
2565
+ dashboard_id: z.string().uuid().optional().describe("Dashboard ID, the same name the other widget tools take"),
2566
+ id: z.string().uuid().optional().describe("Same as dashboard_id, the name this tool has always taken. Send either one."),
2439
2567
  },
2440
2568
  }, async (params) => {
2441
2569
  try {
2442
- const res = await api.get(`/dashboards/${params.id}/widgets`);
2570
+ const res = await api.get(`/dashboards/${requireId(params.dashboard_id, params.id, "dashboard_id")}/widgets`);
2443
2571
  return text(res);
2444
2572
  }
2445
2573
  catch (e) {
@@ -2449,7 +2577,7 @@ export function createServer(apiKey) {
2449
2577
  server.registerTool("lighthouse_reports_create_widget", {
2450
2578
  title: "Create Report Widget",
2451
2579
  description: "Create a report widget (chart/table/KPI) on a dashboard. " +
2452
- "WORKFLOW: 1) Call lighthouse_crm_get_attributes to discover field keys for the entity type, " +
2580
+ "WORKFLOW: 1) Call lighthouse_crm_list_attributes to discover field keys for the entity type, " +
2453
2581
  "2) Build a config object using those keys, 3) Create the widget. " +
2454
2582
  "Widget types: bar, line, pie, table, kpi, funnel, area. " +
2455
2583
  "Report types: standard (aggregate by field), funnel (pipeline conversion), stage_changes (historical transitions). " +
@@ -2513,9 +2641,10 @@ export function createServer(apiKey) {
2513
2641
  title: "Get Widget Report Data",
2514
2642
  description: "Fetch the aggregated report data for a specific widget. " +
2515
2643
  "Returns the computed data based on the widget's config (dimensions, measures, segments, filters). " +
2516
- "For standard reports: array of {label, value, segment?} rows (label = the grouped dimension value, value = the aggregated measure, segment = present only when segment_by is set). " +
2644
+ "For standard reports: array of {label, label_is_empty, value, segment?, segment_is_empty?} rows (label = the grouped dimension value, value = the aggregated measure, segment = present only when segment_by is set). " +
2517
2645
  "For funnel reports: array of {stage, count} rows. " +
2518
- "For historical reports: array of {period, from_stage, to_stage, transition_count} rows.",
2646
+ "For historical reports: array of {period, from_stage, to_stage, transition_count} rows. " +
2647
+ "Records with no value for the grouped field are gathered into one group whose label depends on the field and on where the grouping happens: '(empty)' when the field is the dimension, '(none)' when it is segment_by or a stage on a stage-changes report, '(unassigned)' on a person field (owner, created by, assignee, any user field) in either position, and '(not connected)' on the relationship-connector dimension. These are labels, not values. On a standard report `label_is_empty` (and `segment_is_empty` when the report is segmented) is true on that group and false on a group of real values: read the flag rather than the label, and describe such a row as \"no value\". A stored value spelled like one of those sentences shares that group, so the flag describes the group and not a single value. A funnel or stage-changes report has no flag, because '(none)' is a stage value there.",
2519
2648
  inputSchema: {
2520
2649
  dashboard_id: z.string().uuid().describe("Dashboard ID"),
2521
2650
  widget_id: z.string().uuid().describe("Widget ID"),
@@ -2533,10 +2662,16 @@ export function createServer(apiKey) {
2533
2662
  title: "Share Dashboard with Teams",
2534
2663
  description: "Grant team-level access to a dashboard. Only the dashboard owner can share. " +
2535
2664
  "Use lighthouse_workspace_list_teams to find team IDs first. " +
2536
- "Requires reports.edit permission.",
2665
+ "Requires reports.edit permission. " +
2666
+ "Pass share_along: true ONLY when the user asked to share the lists as well; a list is shared along only when " +
2667
+ "the user's role can share lists. The response always carries shared_along and audience_cannot_open.",
2537
2668
  inputSchema: {
2538
2669
  id: z.string().uuid().describe("Dashboard ID"),
2539
2670
  team_ids: z.array(z.string().uuid()).describe("Array of team UUIDs to grant access to"),
2671
+ share_along: z
2672
+ .boolean()
2673
+ .optional()
2674
+ .describe("Also share the lists you own that this dashboard's widgets filter on with the same people. Nothing is shared along unless you pass true."),
2540
2675
  },
2541
2676
  }, async (params) => {
2542
2677
  try {
@@ -2571,10 +2706,16 @@ export function createServer(apiKey) {
2571
2706
  description: "Grant team-level access to a CRM view or list view. Only the view owner can share. " +
2572
2707
  "Use lighthouse_workspace_list_teams to find team IDs first. " +
2573
2708
  "Requires views.share permission. " +
2574
- "Returns formatted view: {id, name, entity_type, view_type, filters, sort, grouped_by, sharing, owner, shared_with_teams, created_at}.",
2709
+ "Returns formatted view: {id, name, record_type, list_id, view_type, filters, sort, grouped_by, sharing, owner, is_owner, shared_with_teams, created_at, shared_along, audience_cannot_open}, where is_owner says whether the API key's user owns the view. " +
2710
+ "Pass share_along: true ONLY when the user asked to share the lists as well; a list is shared along only when " +
2711
+ "the user's role can share lists. The response always carries shared_along and audience_cannot_open.",
2575
2712
  inputSchema: {
2576
2713
  id: z.string().uuid().describe("View ID"),
2577
2714
  team_ids: z.array(z.string().uuid()).describe("Array of team UUIDs to grant access to"),
2715
+ share_along: z
2716
+ .boolean()
2717
+ .optional()
2718
+ .describe("Also share the lists you own that this view filters on with the same people. Nothing is shared along unless you pass true."),
2578
2719
  },
2579
2720
  }, async (params) => {
2580
2721
  try {
@@ -2590,7 +2731,7 @@ export function createServer(apiKey) {
2590
2731
  title: "Revoke View Team Access",
2591
2732
  description: "Revoke team-level access from a CRM view or list view. Only the view owner can revoke. " +
2592
2733
  "Requires views.share permission. " +
2593
- "Returns formatted view: {id, name, entity_type, view_type, filters, sort, grouped_by, sharing, owner, shared_with_teams, created_at}.",
2734
+ "Returns formatted view: {id, name, record_type, list_id, view_type, filters, sort, grouped_by, sharing, owner, is_owner, shared_with_teams, created_at}, where is_owner says whether the API key's user owns the view.",
2594
2735
  inputSchema: {
2595
2736
  id: z.string().uuid().describe("View ID"),
2596
2737
  team_ids: z.array(z.string().uuid()).describe("Array of team UUIDs to revoke access from"),
@@ -2609,10 +2750,16 @@ export function createServer(apiKey) {
2609
2750
  title: "Update View Sharing Status",
2610
2751
  description: "Change a CRM view or list view's sharing visibility. Setting to 'private' also removes all team permissions. " +
2611
2752
  "Automatically detects view type. Only the view owner can change sharing. Requires views.share permission. " +
2612
- "Returns formatted view: {id, name, entity_type, view_type, filters, sort, grouped_by, sharing, owner, shared_with_teams, created_at}.",
2753
+ "Returns formatted view: {id, name, record_type, list_id, view_type, filters, sort, grouped_by, sharing, owner, is_owner, shared_with_teams, created_at, shared_along, audience_cannot_open}, where is_owner says whether the API key's user owns the view. " +
2754
+ "Pass share_along: true ONLY when the user asked to share the lists as well; a list is shared along only when " +
2755
+ "the user's role can share lists. The response always carries shared_along and audience_cannot_open.",
2613
2756
  inputSchema: {
2614
2757
  id: z.string().uuid().describe("View ID"),
2615
2758
  sharing: z.enum(["private", "workspace"]).describe("New sharing visibility"),
2759
+ share_along: z
2760
+ .boolean()
2761
+ .optional()
2762
+ .describe("Also share the lists you own that this view filters on with the same people. Nothing is shared along unless you pass true."),
2616
2763
  },
2617
2764
  }, async (params) => {
2618
2765
  try {
@@ -2627,11 +2774,15 @@ export function createServer(apiKey) {
2627
2774
  server.registerTool("lighthouse_inbox_email_accounts", {
2628
2775
  title: "List Email Accounts",
2629
2776
  description: "List the email accounts the user can access: their own connected mailbox plus mailboxes other workspace " +
2630
- "members shared with them. Each account carries `status` (connected | disconnected | pending, where disconnected " +
2631
- "means the account needs to be reconnected) and `permissions` flags: email_metadata (see subject/snippet), " +
2632
- "email_view (read full emails), email_send, email_create_draft, calendar_view. " +
2633
- "Use scope 'me' to get only the user's own account (single object or null). " +
2634
- "Account ids can be passed as account_id to the message tools to scope them to one mailbox.",
2777
+ "members shared with them. One account object on all three channels: { id, name (the owner's display name), " +
2778
+ "email (the mailbox address), is_owner (true when the account belongs to the API key's user, false for a colleague's), " +
2779
+ "provider (google | microsoft | other), status (connected | pending | disconnected, where disconnected means the " +
2780
+ "account needs to be reconnected), status_reason (credentials | error | stopped | other; null unless disconnected), " +
2781
+ "owner (the workspace member who connected it), permissions { email_metadata (see subject and preview), " +
2782
+ "email_view (read full messages), email_send, email_create_draft }, created_at }. " +
2783
+ "Use scope 'me' to get only the user's own account: the SAME object, or null. " +
2784
+ "An account id is what account_id takes on the thread tools, and what a thread's or a message's account.id matches. " +
2785
+ "Calendars are a separate channel, listed by lighthouse_inbox_calendar_accounts.",
2635
2786
  inputSchema: {
2636
2787
  scope: z.enum(["me", "all"]).default("all").describe("'me' = only the user's own account; 'all' = own + shared"),
2637
2788
  },
@@ -2648,9 +2799,14 @@ export function createServer(apiKey) {
2648
2799
  server.registerTool("lighthouse_inbox_linkedin_accounts", {
2649
2800
  title: "List LinkedIn Accounts",
2650
2801
  description: "List the LinkedIn messaging accounts the user can access: their own connected account plus accounts other " +
2651
- "workspace members shared with them. Each account carries `status` (connected | disconnected | pending) and " +
2652
- "`permissions` flags: message_view (read messages), message_send. " +
2653
- "Use scope 'me' to get only the user's own account (single object or null).",
2802
+ "workspace members shared with them. The same account object the email and calendar channels return: " +
2803
+ "{ id, name (the owner's display name), email (always null on LinkedIn), is_owner (true when the account belongs " +
2804
+ "to the API key's user), provider ('linkedin'), status (connected | pending | disconnected), " +
2805
+ "status_reason (why a disconnected account stopped: credentials | error | stopped | other; null otherwise), " +
2806
+ "owner (the workspace member who connected it), permissions { message_view (read messages), message_send }, created_at }. " +
2807
+ "Use scope 'me' to get only the user's own account: the SAME object, or null. " +
2808
+ "WHEN TO USE: to find the account_id that narrows lighthouse_inbox_linkedin_chats to one account, to say " +
2809
+ "whose account a chat's account.id is, or to answer whether a LinkedIn account is still working and why not.",
2654
2810
  inputSchema: {
2655
2811
  scope: z.enum(["me", "all"]).default("all").describe("'me' = only the user's own account; 'all' = own + shared"),
2656
2812
  },
@@ -2664,55 +2820,79 @@ export function createServer(apiKey) {
2664
2820
  return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
2665
2821
  }
2666
2822
  });
2667
- server.registerTool("lighthouse_inbox_email_conversations", {
2668
- title: "List Email Conversations",
2669
- description: "List email conversations across every email account the user can read, newest activity first. Filters are " +
2823
+ server.registerTool("lighthouse_inbox_email_threads", {
2824
+ title: "List Email Threads",
2825
+ description: "List email threads (conversations) across every email account the user can read, newest activity first. Filters are " +
2670
2826
  "all optional and combinable: record_id + record_type ('company' or 'person' ONLY) for correspondence with " +
2671
- "a CRM record (person = their email addresses, company = its domains); email / domain / free-text query; " +
2672
- "account_id; after / before dates; unread. No filters = most recent conversations. Great for due diligence: " +
2673
- "pull all correspondence with a company via record_id or domain, then read each conversation. " +
2674
- "Live provider data, so no offset: after/before pick the window and a page continues with cursor = meta.next_cursor (same filters) while meta.has_more is true. Conversation ids " +
2675
- "are account-specific: read one with lighthouse_inbox_email_conversation, passing the row's account.id. " +
2676
- "Check meta.accounts for per-account failures.",
2827
+ "a CRM record (person = their email addresses, company = its domains); emails / domains (comma separated lists); " +
2828
+ "query (matches WORDS in any order; a value in quotes stays a phrase); " +
2829
+ "account_id; after / before dates (a date-only before includes that whole day); is_read. No filters = most recent threads. " +
2830
+ "Great for due diligence: pull all correspondence with a company via record_id or domains, then read each thread. " +
2831
+ "Each row: { id, subject, date (the thread's latest activity), is_read, is_starred, has_attachments, has_drafts, " +
2832
+ "message_count (messages, never drafts; null when there are none), participants [{ name, email }] (the OTHER people, " +
2833
+ "the account's own address left out), last_message { id, date, direction (sent | received | draft), from { name, email }, snippet } " +
2834
+ "(the preview lives here, there is none at the row's root), account { id, name, email, is_owner } (the mailbox it was read through) }. " +
2835
+ "Live provider data, so no offset: after/before pick the window and a page continues with cursor = meta.next_cursor (same filters) while meta.has_more is true. Thread ids " +
2836
+ "are account-specific: read one with lighthouse_inbox_email_thread, passing the row's account.id. " +
2837
+ "meta.accounts holds one entry per account the user can see, { id, name, email, is_owner, ok, error, count }: check it for per-account failures. " +
2838
+ "meta.note explains an empty page (a record with no addresses, no mailbox connected).",
2677
2839
  inputSchema: {
2678
2840
  record_id: z.string().uuid().optional().describe("CRM record ID (requires record_type)"),
2679
2841
  record_type: z.enum(["company", "person"]).optional().describe("Type of the CRM record (company or person only)"),
2680
- email: z.string().optional().describe("Email address to search correspondence with (comma-separate for several)"),
2681
- domain: z.string().optional().describe("Domain to search correspondence with, e.g. example.com (comma-separate for several)"),
2682
- query: z.string().optional().describe("Free-text search; combined with record/email/domain it narrows those results"),
2842
+ emails: z.string().optional().describe("Email addresses to match correspondence with, comma separated (e.g. jane@example.com,partner@example.com)"),
2843
+ domains: z.string().optional().describe("Domains to match correspondence with, comma separated (e.g. example.com,acme.com)"),
2844
+ query: z.string().optional().describe("Free-text search, matching words in any order (quote a value to keep it a phrase); combined with record/emails/domains it narrows those results"),
2683
2845
  account_id: z.string().uuid().optional().describe("Only this email account (id from lighthouse_inbox_email_accounts)"),
2684
- after: z.string().optional().describe("Only conversations active on/after this date (e.g. 2024-01-31)"),
2685
- before: z.string().optional().describe("Only conversations active on/before this date"),
2686
- unread: z.boolean().optional().describe("Only conversations with (true) / without (false) unread messages"),
2687
- limit: z.number().min(1).max(100).default(25).describe("Max conversations returned after merging accounts"),
2846
+ after: z.string().optional().describe("Only threads active on/after this date (e.g. 2024-01-31)"),
2847
+ before: z.string().optional().describe("Only threads active on/before this date; a date-only value includes that whole day"),
2848
+ is_read: z.boolean().optional().describe("Only read threads (true) or only threads with unread messages (false)"),
2849
+ limit: z.number().min(1).max(100).default(25).describe("Max threads returned after merging accounts"),
2688
2850
  cursor: z.string().optional().describe("meta.next_cursor of the previous page, with the same filters"),
2851
+ email: z.string().optional().describe(RETIRED_INPUTS.email),
2852
+ domain: z.string().optional().describe(RETIRED_INPUTS.domain),
2689
2853
  },
2690
2854
  }, async (params) => {
2691
2855
  try {
2692
- const res = await api.get("/inbox/email/conversations", params);
2856
+ const res = await api.get("/inbox/email/threads", params);
2693
2857
  return text(res);
2694
2858
  }
2695
2859
  catch (e) {
2696
2860
  return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
2697
2861
  }
2698
2862
  });
2699
- server.registerTool("lighthouse_inbox_email_conversation", {
2700
- title: "Get Email Conversation",
2701
- description: "Read one email conversation: the conversation object plus its full messages, newest first: structured " +
2702
- "from/to/cc/bcc, attachment metadata, and the HTML body when the user can read full emails on that account " +
2703
- "(metadata-only access returns subject/snippet and data.limited_access: true). Conversation ids are " +
2704
- "account-specific: BOTH conversation_id AND account_id (the conversation row's account.id from " +
2705
- "lighthouse_inbox_email_conversations) are required. Long conversations page with a CURSOR like the other inbox lists: pass meta.next_cursor as cursor while meta.has_more is true. A message with no body answers body: null.",
2863
+ server.registerTool("lighthouse_inbox_email_thread", {
2864
+ title: "Get Email Thread",
2865
+ description: "Read one email thread: data.thread (the list's row) plus data.messages, newest first by default. Each message: " +
2866
+ "{ id, thread_id, date, direction (sent | received), from { name, email }, to / cc / bcc / reply_to [{ name, email }], " +
2867
+ "subject, snippet, text (the plain words of THIS message, with the quoted history of a reply removed and a forwarded block kept), " +
2868
+ "body (the full HTML, null when there is none), reply_to_message_id (the message this one answers, when the mailbox states it), " +
2869
+ "is_read, is_starred, attachments [{ id, message_id, type (image | video | audio | file | other), filename, content_type, size }], " +
2870
+ "inline_images (the files embedded in the body: the same keys plus cid), account { id, name, email, is_owner } }. Every key is always present. " +
2871
+ "Read text when you need what was said and body only when the markup matters. " +
2872
+ "When the user can see only the metadata of that account, text and body are null, both file lists are empty and data.has_limited_access is true (false otherwise). Thread ids are " +
2873
+ "account-specific: BOTH thread_id AND account_id (the thread row's account.id from " +
2874
+ "lighthouse_inbox_email_threads) are required. " +
2875
+ "order 'desc' (newest first, the default) pages with a CURSOR like the other inbox lists: pass meta.next_cursor as cursor while meta.has_more is true. " +
2876
+ "order 'asc' (oldest first) answers ONE page and no next_cursor, so raise limit when a long thread must be read from its start; asc together with cursor is refused. " +
2877
+ "direction keeps one side of the conversation. Drafts are not served here.",
2706
2878
  inputSchema: {
2707
- conversation_id: z.string().describe("Conversation ID from lighthouse_inbox_email_conversations"),
2708
- account_id: z.string().uuid().describe("Required: the email account holding the conversation (row's account.id)"),
2709
- limit: z.number().min(1).max(100).default(15).describe("Max messages per page. Bodies are full HTML and can be large, so raise this deliberately"),
2710
- cursor: z.string().optional().describe("meta.next_cursor of the previous page, to fetch the next page of a long conversation"),
2879
+ thread_id: z.string().describe("Thread ID from lighthouse_inbox_email_threads"),
2880
+ account_id: z.string().uuid().describe("Required: the email account holding the thread (row's account.id)"),
2881
+ order: z
2882
+ .enum(["desc", "asc"])
2883
+ .optional()
2884
+ .describe("'desc' (newest first, the default, paged with cursor) or 'asc' (oldest first, one page only)"),
2885
+ direction: z
2886
+ .enum(["sent", "received"])
2887
+ .optional()
2888
+ .describe("Only one side: 'sent' (what the account sent) or 'received' (what the other people sent). Both when omitted"),
2889
+ limit: z.number().min(1).max(100).default(THREAD_MESSAGES_PAGE_DEFAULT).describe("Max messages per page. Bodies are full HTML and can be large, so raise this deliberately"),
2890
+ cursor: z.string().optional().describe("meta.next_cursor of the previous page, to fetch the next page of a long thread (order 'desc' only)"),
2711
2891
  },
2712
2892
  }, async (params) => {
2713
2893
  try {
2714
- const { conversation_id, ...rest } = params;
2715
- const res = await api.get(`/inbox/email/conversations/${encodeURIComponent(conversation_id)}`, rest);
2894
+ const { thread_id, ...rest } = params;
2895
+ const res = await api.get(`/inbox/email/threads/${encodeURIComponent(thread_id)}`, rest);
2716
2896
  return text(res);
2717
2897
  }
2718
2898
  catch (e) {
@@ -2725,20 +2905,25 @@ export function createServer(apiKey) {
2725
2905
  "user to review, edit and send themselves, and there is no tool that can send it. Use this to prepare a reply " +
2726
2906
  "or a new email on the user's behalf. account_id is REQUIRED (from lighthouse_inbox_email_accounts) and must " +
2727
2907
  "be an account whose permissions include email_create_draft. At least one recipient is required across to, cc " +
2728
- "and bcc. To draft a reply, pass reply_to_message_id (a MESSAGE id from lighthouse_inbox_email_conversation, " +
2729
- "not a conversation id) plus conversation_id, and set the recipients explicitly: a reply does not inherit them. " +
2730
- "body is HTML. Attachments are not supported here; use the REST API for those. " +
2731
- "Returns the saved draft: {id, conversation_id, reply_to_message_id, date, subject, body, from, to, cc, bcc, " +
2732
- "attachments, account}.",
2908
+ "and bcc; each of the three takes an address, a { name, email } object, or an array of either, so the from or the to " +
2909
+ "of a message read with lighthouse_inbox_email_thread can be handed straight back. " +
2910
+ "To draft a reply, pass reply_to_message_id (a MESSAGE id from lighthouse_inbox_email_thread, " +
2911
+ "not a thread id) plus thread_id, and set the recipients explicitly: a reply does not inherit them. " +
2912
+ "Write the message as text (plain words, kept as lines and paragraphs) or as body (HTML), never both. " +
2913
+ "Attachments are not supported here; use the REST API for those. " +
2914
+ "Returns the saved draft as a message with direction 'draft', the same key set a message has: " +
2915
+ "{ id, thread_id, date, direction, from, to, cc, bcc, reply_to, subject, snippet, text, body, reply_to_message_id, " +
2916
+ "is_read, is_starred, attachments, inline_images, account }.",
2733
2917
  inputSchema: {
2734
2918
  account_id: z.string().uuid().describe("Required: the mailbox to save the draft in (id from lighthouse_inbox_email_accounts)"),
2735
- to: z.array(z.string()).optional().describe("Recipient email addresses"),
2736
- cc: z.array(z.string()).optional().describe("Cc email addresses"),
2737
- bcc: z.array(z.string()).optional().describe("Bcc email addresses"),
2919
+ to: RECIPIENTS.optional().describe("Recipients: an address, a { name, email } object, or an array of either"),
2920
+ cc: RECIPIENTS.optional().describe("Cc recipients: an address, a { name, email } object, or an array of either"),
2921
+ bcc: RECIPIENTS.optional().describe("Bcc recipients: an address, a { name, email } object, or an array of either"),
2738
2922
  subject: z.string().optional().describe("Subject line"),
2739
- body: z.string().optional().describe("Message body as HTML"),
2740
- conversation_id: z.string().optional().describe("Save the draft inside this existing conversation (id from lighthouse_inbox_email_conversations)"),
2741
- reply_to_message_id: z.string().optional().describe("Message id this draft replies to, from lighthouse_inbox_email_conversation. Required for the reply to thread correctly"),
2923
+ text: z.string().optional().describe("Message as plain words; lines and paragraphs are kept. Use this or body, never both"),
2924
+ body: z.string().optional().describe("Message body as HTML. Use this or text, never both"),
2925
+ thread_id: z.string().optional().describe("Save the draft inside this existing thread (id from lighthouse_inbox_email_threads)"),
2926
+ reply_to_message_id: z.string().optional().describe("Message id this draft replies to, from lighthouse_inbox_email_thread. Required for the reply to thread correctly"),
2742
2927
  },
2743
2928
  }, async (params) => {
2744
2929
  try {
@@ -2754,20 +2939,32 @@ export function createServer(apiKey) {
2754
2939
  description: "List LinkedIn conversations across every LinkedIn account the user can view, newest activity first, " +
2755
2940
  "paginated. Filters are all optional and combinable: record_id + record_type ('company' or 'person' ONLY, where a " +
2756
2941
  "company matches its team members and company page), person_linkedin / company_linkedin (LinkedIn URL or " +
2757
- "slug), account_id, unread, and a free-text query on conversation/participant names. Each chat carries " +
2758
- "attendees, last_message, unread_count, and the account it lives in. Use " +
2942
+ "handle), account_id, is_read, after / before (on the conversation's own latest activity, both inclusive; a " +
2943
+ "date-only before covers that whole day), and a free-text query on conversation and participant names. " +
2944
+ "Each chat: { id, name, type (message | inmail | sponsored | offer | other), date (its latest activity), " +
2945
+ "is_read, unread_count, is_group, is_archived, participants (the OTHER people only, each " +
2946
+ "{ name, linkedin (bare handle or null), linkedin_url, avatar_url, headline, connection_degree " +
2947
+ "(first | second | third | out_of_network | self | null) }), " +
2948
+ "last_message { id, date, direction (sent | received), from (the same person object), text }, " +
2949
+ "account { id, name, email (always null on LinkedIn), is_owner } }. Use " +
2759
2950
  "lighthouse_inbox_linkedin_chat_messages with a chat id to read a conversation. Great for due diligence: " +
2760
- "filter chats by record_id or company_linkedin, then read each conversation.",
2951
+ "filter chats by record_id or company_linkedin, then read each conversation. WHEN TO USE: 'what did we " +
2952
+ "discuss with this founder on LinkedIn', 'LinkedIn conversations with anyone at Acme', 'my unread LinkedIn " +
2953
+ "messages' (is_read false). Whether a chat sits in the user's own account or a colleague's is its " +
2954
+ "account.is_owner, and account.id matches lighthouse_inbox_linkedin_accounts.",
2761
2955
  inputSchema: {
2762
2956
  record_id: z.string().uuid().optional().describe("CRM record ID (requires record_type)"),
2763
2957
  record_type: z.enum(["company", "person"]).optional().describe("Type of the CRM record (company or person only)"),
2764
- person_linkedin: z.string().optional().describe("Person LinkedIn URL or slug, e.g. https://linkedin.com/in/username"),
2765
- company_linkedin: z.string().optional().describe("Company LinkedIn URL or slug, e.g. https://linkedin.com/company/acme"),
2958
+ person_linkedin: z.string().optional().describe("Person LinkedIn URL or handle, e.g. https://linkedin.com/in/username or username. Also matches the CRM person with that handle"),
2959
+ company_linkedin: z.string().optional().describe("Company LinkedIn URL or handle, e.g. https://linkedin.com/company/acme or acme. Also matches the CRM company with that handle and its people"),
2766
2960
  account_id: z.string().uuid().optional().describe("Only this LinkedIn account (id from lighthouse_inbox_linkedin_accounts)"),
2767
- unread: z.boolean().optional().describe("Only chats with (true) / without (false) unread messages"),
2961
+ is_read: z.boolean().optional().describe("Only conversations with nothing unread (true) or only conversations that have unread messages (false)"),
2768
2962
  query: z.string().optional().describe("Text search on conversation and participant names"),
2963
+ after: z.string().optional().describe("Only conversations active on/after this date (e.g. 2024-01-31)"),
2964
+ before: z.string().optional().describe("Only conversations active on/before this date; a date-only value includes that whole day"),
2769
2965
  limit: z.number().min(1).max(100).default(25).describe("Conversations per page"),
2770
2966
  offset: z.number().min(0).default(0).describe("Conversations to skip for pagination"),
2967
+ unread: z.boolean().optional().describe(RETIRED_INPUTS.unread),
2771
2968
  },
2772
2969
  }, async (params) => {
2773
2970
  try {
@@ -2780,13 +2977,33 @@ export function createServer(apiKey) {
2780
2977
  });
2781
2978
  server.registerTool("lighthouse_inbox_linkedin_chat_messages", {
2782
2979
  title: "Get LinkedIn Chat Messages",
2783
- description: "Read one LinkedIn conversation: returns the chat object (attendees, account) plus its messages (full text, " +
2784
- "InMail subject, attachment metadata), newest first, paginated, with optional after/before date bounds. " +
2980
+ description: "Read one LinkedIn conversation: returns the chat object (the same object lighthouse_inbox_linkedin_chats " +
2981
+ "returns) plus its messages, paginated, newest first by default (order 'asc' for oldest first), both sides " +
2982
+ "or one (direction 'sent' = what the account sent, 'received' = what the other people sent), with optional " +
2983
+ "after/before date bounds (both inclusive; a date-only before covers that whole day). Each message: " +
2984
+ "{ id, chat_id, date, direction, from (who wrote it, a person { name, linkedin, linkedin_url, avatar_url, headline, " +
2985
+ "connection_degree }; on a message the account sent it is the account's own profile with connection_degree 'self'), " +
2986
+ "subject (InMail), text (null when it has none), type (message | inmail | inmail_accepted | inmail_declined), " +
2987
+ "is_seen and is_delivered (the other side's receipts on a message the account SENT, null on a received one), " +
2988
+ "attachments [{ id, message_id, type (image | video | audio | file | other), filename, content_type, size }], " +
2989
+ "reactions [{ emoji, direction }], account { id, name, email (always null on LinkedIn), is_owner } }. " +
2990
+ "Read state is per conversation on LinkedIn (the chat's is_read and unread_count), never per message. " +
2991
+ "meta.total counts the messages that " +
2992
+ "match the dates and the direction. WHEN TO USE: reading a conversation in full, or only the replies after " +
2993
+ "a moment (after = that moment, direction 'received', order 'asc'). " +
2785
2994
  "Get chat ids from lighthouse_inbox_linkedin_chats.",
2786
2995
  inputSchema: {
2787
2996
  chat_id: z.string().describe("Chat ID from lighthouse_inbox_linkedin_chats"),
2788
2997
  after: z.string().optional().describe("Only messages on/after this date (e.g. 2024-01-31)"),
2789
- before: z.string().optional().describe("Only messages on/before this date"),
2998
+ before: z.string().optional().describe("Only messages on/before this date; a date-only value includes that whole day"),
2999
+ direction: z
3000
+ .enum(["sent", "received"])
3001
+ .optional()
3002
+ .describe("Only one side: 'sent' (what the account sent) or 'received' (what the other people sent). Both when omitted"),
3003
+ order: z
3004
+ .enum(["desc", "asc"])
3005
+ .optional()
3006
+ .describe("'desc' (newest first, the default) or 'asc' (oldest first)"),
2790
3007
  limit: z.number().min(1).max(100).default(25).describe("Messages per page"),
2791
3008
  offset: z.number().min(0).default(0).describe("Messages to skip for pagination"),
2792
3009
  },
@@ -2802,13 +3019,20 @@ export function createServer(apiKey) {
2802
3019
  });
2803
3020
  server.registerTool("lighthouse_inbox_calendar_accounts", {
2804
3021
  title: "List Calendar Accounts",
2805
- description: "Calendars the API key's user can read: their own connected calendar (is_own true) and colleagues' calendars shared with them, each with id, email, provider, status, owner and permissions { calendar_view }. " +
3022
+ description: "Calendars the API key's user can read: their own connected calendar (is_owner true) and colleagues' calendars shared with them. " +
3023
+ "The same account object the email and LinkedIn channels return: { id, name, email, is_owner, provider (google | microsoft | other), " +
3024
+ "status (connected | pending | disconnected), status_reason (credentials | error | stopped | other; null unless disconnected), " +
3025
+ "owner, permissions { calendar_view }, created_at }. " +
2806
3026
  "A calendar is the same connected account as a mailbox, behind its own sharing switch, so a mailbox shared for mail only is NOT listed here. " +
3027
+ "Use scope 'me' to get only the user's own calendar: the SAME object, or null. " +
2807
3028
  "Use it to know whose meetings lighthouse_inbox_calendar_meetings can see, or to get an account_id to narrow that tool to one calendar. Returns [] when no calendar is connected.",
2808
- inputSchema: {},
2809
- }, async () => {
3029
+ inputSchema: {
3030
+ scope: z.enum(["me", "all"]).default("all").describe("'me' = only the user's own calendar; 'all' = own + shared"),
3031
+ },
3032
+ }, async (params) => {
2810
3033
  try {
2811
- const res = await api.get("/inbox/calendar/accounts");
3034
+ const path = params.scope === "me" ? "/inbox/calendar/accounts/me" : "/inbox/calendar/accounts";
3035
+ const res = await api.get(path);
2812
3036
  return text(res);
2813
3037
  }
2814
3038
  catch (e) {
@@ -2818,24 +3042,26 @@ export function createServer(apiKey) {
2818
3042
  server.registerTool("lighthouse_inbox_calendar_meetings", {
2819
3043
  title: "List Meetings",
2820
3044
  description: "Calendar meetings across every calendar the API key's user can read (their own, plus colleagues' calendars shared with them), read live from the calendar. PAST meetings newest first by default (the last 6 months; pass months up to 36, or after / before, to move the window), or the SCHEDULED ones soonest first with upcoming=true (90 days ahead by default; an explicit before reaches further). " +
2821
- "Narrow by CRM record (record_id + record_type, company or person: the record's addresses and domains are matched), by email / domain lists (comma-separated), by words in the meeting TITLE (query; never the notes), or by one calendar (account_id from lighthouse_inbox_calendar_accounts). No filter lists the user's whole calendar over the window. " +
3045
+ "Narrow by CRM record (record_id + record_type, company or person: the record's addresses and domains are matched), by emails / domains lists (comma separated), by words in the meeting TITLE (query; never the notes), or by one calendar (account_id from lighthouse_inbox_calendar_accounts). No filter lists the user's whole calendar over the window. " +
2822
3046
  "WHEN TO USE: \"when did we last meet X\", \"what meetings do I have next week\", \"have we talked to anyone at acme.com\", \"which calls with this company were recorded\". To find what was DISCUSSED about a topic, list the meetings and read the notes of the likely ones. " +
2823
- "Each row: id, meeting_key (only when the meeting was recorded), title, description, location, start, end, is_all_day, is_recurring (a recurring meeting appears once per occurrence), status, meeting_link, organizer { name, email }, attendees [{ name, email, response_status }] (response_status is accepted | declined | tentative | needs_action; a missing title, description, location, name or email is null, never an empty string), account { id, email, is_own } (the calendar it was read from) and recording: null when not recorded, else { has_recording, has_transcript, has_notes, duration_seconds, language, recorded_by }. " +
3047
+ "Each row: { id, meeting_key (only when the meeting was recorded), ical_uid, title, description, location, start, end, is_all_day, is_recurring (a recurring meeting appears once per occurrence), status (confirmed | tentative | cancelled | null), meeting_url (the video link), organizer { name, email }, attendees [{ name, email, response_status }] (everyone invited, the calendar's own account included; response_status is accepted | declined | tentative | needs_action, where needs_action means that attendee has not answered yet; a missing title, description, location, name or email is null, never an empty string), account { id, name, email, is_owner } (the calendar it was read from), recording: null when not recorded, else { has_recording, has_transcript, has_notes, duration_seconds, language, recorded_by }, the same object lighthouse_inbox_calendar_meeting returns (recorded_by is the recorder as a workspace member { id, first_name, last_name, full_name, avatar_url, email }, or null; has_notes is true only when notes are ready and the user may view notes) }. " +
2824
3048
  "A row with meeting_key is a recorded meeting: pass it to lighthouse_inbox_calendar_meeting_notes for what was discussed and decided, or lighthouse_inbox_calendar_meeting_transcript for what was said. " +
2825
- "Paged with a CURSOR: pass meta.next_cursor as cursor (same filters) while meta.has_more is true; the window is walked from its near end in 30-day steps, so the first page always holds the newest (or soonest) meetings. meta.complete false means a step could not be fully scanned in time: filter by record, email or domain, or narrow the window. meta.note explains an empty result (no calendar connected, a record with no addresses).",
3049
+ "Paged with a CURSOR: pass meta.next_cursor as cursor (same filters) while meta.has_more is true; the window is walked from its near end in 30-day steps, so the first page always holds the newest (or soonest) meetings. A date-only before includes that whole day. meta.window { after, before } is the window that was read. meta.accounts lists every calendar the user can see, one entry of { id, name, email, is_owner, ok, error, count } (a calendar that could not be read is ok false with its error code while the others are still read). meta.complete false means a step could not be fully scanned in time or a calendar failed: filter by record, emails or domains, or narrow the window. meta.note explains an empty or partial result (no calendar connected, a record with no addresses, a calendar that could not be read).",
2826
3050
  inputSchema: {
2827
3051
  record_id: z.string().uuid().optional().describe("Meetings with this CRM record's people (requires record_type)"),
2828
3052
  record_type: z.enum(["company", "person"]).optional().describe("The record's type (requires record_id)"),
2829
- email: z.string().optional().describe("Comma-separated attendee email addresses to match"),
2830
- domain: z.string().optional().describe("Comma-separated attendee domains to match (e.g. acme.com)"),
3053
+ emails: z.string().optional().describe("Attendee email addresses to match, comma separated"),
3054
+ domains: z.string().optional().describe("Attendee domains to match, comma separated (e.g. acme.com,example.com)"),
2831
3055
  query: z.string().optional().describe("Words to match in the meeting title"),
2832
3056
  account_id: z.string().uuid().optional().describe("Only this calendar (id from lighthouse_inbox_calendar_accounts)"),
2833
3057
  after: z.string().optional().describe("Only meetings on/after this date (YYYY-MM-DD)"),
2834
- before: z.string().optional().describe("Only meetings on/before this date (YYYY-MM-DD); with upcoming=true it extends the reach past the 90-day default"),
3058
+ before: z.string().optional().describe("Only meetings on/before this date (YYYY-MM-DD, the whole day included); with upcoming=true it extends the reach past the 90-day default"),
2835
3059
  months: z.number().min(1).max(36).optional().describe("How many months back to look when after is not given (default 6, max 36)"),
2836
3060
  upcoming: z.boolean().optional().describe("true: scheduled meetings from now on, soonest first, instead of past ones"),
2837
3061
  limit: z.number().min(1).max(100).default(25).describe("Meetings per page"),
2838
3062
  cursor: z.string().optional().describe("meta.next_cursor of the previous page, with the same filters"),
3063
+ email: z.string().optional().describe(RETIRED_INPUTS.email),
3064
+ domain: z.string().optional().describe(RETIRED_INPUTS.domain),
2839
3065
  },
2840
3066
  }, async (params) => {
2841
3067
  try {
@@ -2848,7 +3074,7 @@ export function createServer(apiKey) {
2848
3074
  });
2849
3075
  server.registerTool("lighthouse_inbox_calendar_meeting", {
2850
3076
  title: "Get Recorded Meeting",
2851
- description: "One recorded meeting's metadata by meeting_key (from lighthouse_inbox_calendar_meetings, or the meeting object of a meeting note): title, occurred_at, duration_seconds, language, recorded_by, has_recording, has_transcript, has_notes, and notes { note_id, status, created_at } when meeting notes exist (status ready means readable; processing means they are still being written). " +
3077
+ description: "One recorded meeting's metadata by meeting_key (from a row of lighthouse_inbox_calendar_meetings; a meeting note's meeting object carries no meeting_key): { meeting_key, ical_uid, title, occurred_at, meeting_url (the video link), recording { has_recording, has_transcript, has_notes, duration_seconds, language, recorded_by } (the same object the meetings list carries; recorded_by is the recorder as a workspace member, or null), notes { id, status, created_at }: the note lighthouse_inbox_calendar_meeting_notes returns, or null when there is none the user may read }. The title and the time are the same here as on the meetings list and inside the notes. " +
2852
3078
  "WHEN TO USE: to check whether a transcript or notes exist before reading them, or to answer how long a call lasted. It never returns the transcript or the notes themselves. Not found when the meeting was not recorded or the API key's user cannot open its recording.",
2853
3079
  inputSchema: {
2854
3080
  meeting_key: z.string().describe("The meeting_key of a recorded meeting"),
@@ -2864,7 +3090,7 @@ export function createServer(apiKey) {
2864
3090
  });
2865
3091
  server.registerTool("lighthouse_inbox_calendar_meeting_notes", {
2866
3092
  title: "Get Meeting Notes",
2867
- description: "The meeting notes written from one recorded meeting, by meeting_key: the note in the same shape as lighthouse_crm_get_note (type 'meeting', with the meeting object and the records it is linked to), plus content_markdown (what was generated: Meeting Context first, then the sections) and edited (true when a member changed the note since, in which case content is the version to trust). " +
3093
+ description: "The meeting notes written from one recorded meeting, by meeting_key: the note in the same shape as lighthouse_crm_get_note (type 'meeting', with the meeting object and the records it is linked to), plus content_markdown (what was generated: Meeting Context first, then the sections) and is_edited (true when a member changed the note since, in which case content is the version to trust). " +
2868
3094
  "WHEN TO USE: the user asks what was discussed, decided or agreed in a specific meeting, the next steps from a call, or who attended. Lighter than the transcript: prefer it unless the answer depends on exactly what was said. " +
2869
3095
  "Not found when the meeting has no notes yet (see has_notes on the meeting) or the API key's user cannot open its recording.",
2870
3096
  inputSchema: {