@trylighthouse/mcp-server 0.2.17 → 0.2.22

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.
package/build/server.js CHANGED
@@ -57,16 +57,19 @@ function textWithImage(res, image, declaredType) {
57
57
  const mimeType = IMAGE_CONTENT_TYPES.has(image.contentType) ? image.contentType : declaredType;
58
58
  return { ...base, content: [...base.content, { type: "image", data: image.bytes.toString("base64"), mimeType }] };
59
59
  }
60
+ const FILE_BLOCKED_TOOL_RULE = "A file whose blocked_reason is threat_detected cannot be read or downloaded.";
61
+ const FILE_BLOCKED_KEYS = "A file a threat was found in also carries blocked_at and blocked_reason.";
62
+ const FILE_BLOCKED_ANSWER = "Reading or downloading such a file answers 409 file_blocked (\"This file could not be read: a threat was detected in it.\").";
60
63
  const FILE_CONTENT_RULES = "kind is pdf, document, spreadsheet, presentation, html, csv, markdown, text or image (null for a kind that cannot be read); pages_total is a PDF's pages or a presentation's slides. " +
61
64
  "A spreadsheet reads as one \"Sheet: <name>\" block per sheet, a row per line with its cells tab separated (a date shows as its stored serial number); a presentation as one \"Slide <n>\" block per slide. " +
62
- `A long file comes back in windows of max_chars (default ${FILE_CONTENT_WINDOW_CHARS}): meta.has_more true means the file continues, so pass meta.next_offset_chars as offset_chars to read on, and never treat a window with has_more as the end of the file. ` +
65
+ `A long file comes back in windows of max_chars (default ${FILE_CONTENT_WINDOW_CHARS}): meta.has_more true means the file continues, meta.next_offset_chars sent as offset_chars reads on from there, and a window with has_more is not the end of the file. ` +
63
66
  "meta.complete false means the file is longer than can be read here and the text stops before its end. " +
64
- "When text is null, reason says why (unsupported_type, image, too_large, no_text for a scan or an empty file, unreadable for a damaged or password protected one, timed_out) and note says it in words: relay the note, never guess the content. " +
65
- `An image (PNG, JPEG, GIF or WebP up to ${IMAGE_LIMIT_WORDS}) comes back as the image itself after the JSON, so you can look at it. ` +
67
+ "When text is null, reason says why (unsupported_type, image, too_large, no_text for a scan or an empty file, unreadable for a damaged or password protected one, timed_out) and note says it in words. " +
68
+ `An image (PNG, JPEG, GIF or WebP up to ${IMAGE_LIMIT_WORDS}) comes back as the image itself after the JSON. ` +
66
69
  "Older .doc, .xls and .ppt files, archives, audio and video cannot be read.";
67
70
  const MEETING_ACCESS_RULE = "The API key's user may read a recorded meeting (its notes and transcript) when they recorded it, were invited to it (as a guest, declined included, or as its organizer), or a teammate who recorded it shares their meeting notes with them; each occurrence of a recurring meeting is judged on its own.";
68
71
  const MEETING_KEY_SOURCES = "from a lighthouse_inbox_calendar_meetings row, or from the meeting object of a meeting note read with lighthouse_crm_get_note, lighthouse_crm_list_notes or lighthouse_crm_search_notes";
69
- const NOTE_MEETING_KEY_RULE = "meeting.meeting_key is the recorded meeting's handle, the same one a lighthouse_inbox_calendar_meetings row carries: when meeting.has_transcript is true, pass it to lighthouse_inbox_calendar_meeting_transcript for what was actually said. meeting.has_transcript and meeting.has_recording are answered for the API key's user: each is true only when that transcript or recording belongs to this note's occurrence and they may read it, which needs no access to the recorder's calendar (their role permitting). A recurring meeting shares one key and keeps only the transcript of its latest recorded occurrence, so the note of an earlier occurrence says false. lighthouse_inbox_calendar_meeting describes that same latest occurrence, so it answers for the key when either flag is true; lighthouse_inbox_calendar_meeting_notes returns the newest notes of that meeting the user may read. The API returns no recording, only whether one exists.";
72
+ const NOTE_MEETING_KEY_RULE = "meeting.meeting_key is the recorded meeting's handle, the same one a lighthouse_inbox_calendar_meetings row carries: when meeting.has_transcript is true, lighthouse_inbox_calendar_meeting_transcript takes it and answers what was actually said. meeting.has_transcript and meeting.has_recording are answered for the API key's user: each is true only when that transcript or recording belongs to this note's occurrence and they may read it, which needs no access to the recorder's calendar (their role permitting). A recurring meeting shares one key and keeps only the transcript of its latest recorded occurrence, so the note of an earlier occurrence says false. lighthouse_inbox_calendar_meeting describes that same latest occurrence, so it answers for the key when either flag is true; lighthouse_inbox_calendar_meeting_notes returns the newest notes of that meeting the user may read. The API returns no recording, only whether one exists.";
70
73
  const THREAD_MESSAGES_PAGE_DEFAULT = 15;
71
74
  const RETIRED_INPUTS = {
72
75
  email: "email is no longer a parameter. Use emails, a comma separated list of email addresses.",
@@ -85,11 +88,11 @@ const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint:
85
88
  const CREATES = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false };
86
89
  const ADDS_IF_MISSING = { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false };
87
90
  const DESTRUCTIVE = { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false };
88
- export function createServer(apiKey, options = {}) {
91
+ export function createServer(apiKey) {
89
92
  const api = new LighthouseAPI(apiKey);
90
93
  const server = new McpServer({
91
94
  name: "lighthouse",
92
- version: "0.2.17",
95
+ version: "0.2.22",
93
96
  });
94
97
  const originalRegister = server.registerTool.bind(server);
95
98
  server.registerTool = (name, schemaOrConfig, handler) => {
@@ -106,7 +109,7 @@ export function createServer(apiKey, options = {}) {
106
109
  };
107
110
  const CRM_FILTER_DESCRIPTION = "CRM filter object. Format: " +
108
111
  '{"operator":"AND","conditions":[{"field":"<key>","operator":"<op>","value":<val>}],"groups":[]}. ' +
109
- "IMPORTANT: First call lighthouse_crm_list_attributes to discover available field keys and types. " +
112
+ "Field keys and types come from lighthouse_crm_list_attributes. " +
110
113
  "Operators by field type, " +
111
114
  "text/url: contains, not_contains, starts_with, ends_with, eq, neq, empty, not_empty; " +
112
115
  "number/currency/rating/percentage: gt, gte, lt, lte, eq, neq, empty, not_empty; " +
@@ -120,12 +123,12 @@ export function createServer(apiKey, options = {}) {
120
123
  "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; " +
121
124
  "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; " +
122
125
  "domain/email/phone: contains_any, not_contains_any, contains_all, empty, not_empty; " +
123
- "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 the type of the record the condition is on: this record type, or the linked record's type on a dotted path): 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). On a dotted path it filters by the linked records' lists, quantified like every dotted field: associated_people.list_entries contains_any [<people list id>] on a company = the companies with at least one person in that list, associated_people.list_entries empty = the companies none of whose people is in a list, companies with no people included; a custom record-link field works the same way (<link field>.list_entries, the lists of the record type it links to). A custom field can belong to ONE list: lighthouse_crm_list_attributes gives it that list's list_id, it can be set only on a record already in that list, 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; " +
126
+ "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 the type of the record the condition is on: this record type, or the linked record's type on a dotted path): 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). On a dotted path it filters by the linked records' lists, quantified like every dotted field: associated_people.list_entries contains_any [<people list id>] on a company = the companies with at least one person in that list, associated_people.list_entries empty = the companies none of whose people is in a list, companies with no people included; a custom record-link field works the same way (<link field>.list_entries, the lists of the record type it links to). A custom field can belong to ONE list: lighthouse_crm_list_attributes gives it that list's list_id, it can be set only on a record already in that list, 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, with no need to read the whole list; " +
124
127
  "sequences (people only: the outbound sequences a person is in, a read-only multi_select whose options are the sequences you can open; the value is an array of sequence ids, from that field's options (lighthouse_crm_list_options) or the id of an entry of a person's sequences): contains_any, not_contains_any, contains_all, empty, not_empty (empty = in none of the sequences you can open, not_empty = in at least one of them; both take no value). The id of a sequence you cannot open, or an id that names no sequence, matches no one, so it adds no one to contains_any, makes contains_all match no one and removes no one with not_contains_any. A value that is not a sequence id is refused. On a company or a deal, associated_people.sequences filters by the sequences of its people, quantified like every dotted field (associated_people.sequences contains_any [<sequence id>] = the records with at least one person in that sequence); " +
125
128
  "checkbox: eq (value is true or false); " +
126
129
  "interaction (first/last email & linkedin interaction, overall interaction-with): eq, neq, empty, not_empty; " +
127
130
  "interaction-type (first/last interaction type): contains_any, not_contains_any, empty, not_empty. " +
128
- "Value formats: text ops → string; number ops → number (a rating field's value is an integer 1-5, or null when unrated); " +
131
+ "Value formats: text ops → string; number ops → number (a rating field's value is a number from 0 to 5, or null when unrated); " +
129
132
  "contains_any/not_contains_any/contains_all → array of values; " +
130
133
  "empty/not_empty → omit value; date ops → ISO date string \"YYYY-MM-DD\" or a rolling token resolved against the current time on every run: \"7 days\" or \"3 months ago\" = that period back (bare token means ago), \"2 weeks from now\" = that period forward (e.g. next_meeting_date before \"60 days from now\" = within the next 60 days). " +
131
134
  "interaction eq/neq → {\"members\":[teammateUserId,...],\"directions\":[\"outbound\"|\"inbound\"|\"none\"]} " +
@@ -134,25 +137,25 @@ export function createServer(apiKey, options = {}) {
134
137
  "interaction date fields before/after/on → ISO date or a rolling token (\"60 days\", \"60 days from now\"), empty/not_empty → omit value. " +
135
138
  "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. " +
136
139
  "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. " +
137
- "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). " +
140
+ "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 a vector the app did not produce matches nothing meaningful. 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. Such a condition works as it is, 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. With no saved view or copied condition carrying a vector, a description-based search of the CRM is not available; the attribute filters still are, and lighthouse_discovery_semantic_search_companies / _people search the global database (NOT the CRM) by description. " +
138
141
  "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. " +
139
142
  "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. " +
140
143
  'Nested-group example for "(domain contains acme OR name contains acme) AND created after 2025-01-01": ' +
141
144
  '{"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":[]}]} ' +
142
- "(field keys above are illustrative, use the real keys from lighthouse_crm_list_attributes). " +
145
+ "(field keys above are illustrative; the real keys come from lighthouse_crm_list_attributes). " +
143
146
  "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. " +
144
- "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. " +
145
- "Do NOT use discovery field keys here, CRM fields come from lighthouse_crm_list_attributes. " +
146
- "RESPONSE VALIDATION META (searching records, and reading a list's records): an unknown field, an operator the field does not take (list_entries included; on a dotted path, the leaf field's) or a group join other than AND/OR does not fail the read; instead the response carries, alongside `records`/`total`, three arrays you MUST inspect (collected recursively across nested groups): " +
147
+ "Field keys and option values are matched exactly against what lighthouse_crm_list_attributes / lighthouse_crm_list_options return, and the chosen values for a select/multi_select condition are a subset of that field's options. A concept with no matching field or option (e.g. a 'competitor'/'portfolio' status that no field actually holds) cannot be filtered on. " +
148
+ "Discovery field keys do not apply here; CRM fields come from lighthouse_crm_list_attributes. " +
149
+ "RESPONSE VALIDATION META (searching records, and reading a list's records): an unknown field, an operator the field does not take (list_entries included; on a dotted path, the leaf field's) or a group join other than AND/OR does not fail the read; instead the response carries, alongside `records`/`total`, three arrays (collected recursively across nested groups): " +
147
150
  "`ignored_filter_fields` (field keys not recognised; that condition was dropped), " +
148
151
  "`invalid_operators` ([{field, operator}] where the operator is not valid for that field's type; that condition was dropped), and " +
149
152
  "`invalid_logical_operators` ([{operator}] where a group's join was not AND/OR; treated as AND). " +
150
- "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 summarises the same in plain text. " +
151
- "A dotted path is judged by its leaf, and only for its operator: a relation, or a linked record's field, that does not exist is in none of these arrays, and that condition matches no record, so take every key of a dotted path from lighthouse_crm_list_attributes of the record type it is on. " +
153
+ "A non-empty array means part of the filter did NOT apply, so the results are broader or narrower than it asked for. A `warnings` array summarises the same in plain text. " +
154
+ "A dotted path is judged by its leaf, and only for its operator: a relation, or a linked record's field, that does not exist is in none of these arrays, and that condition matches no record; the keys of a dotted path come from lighthouse_crm_list_attributes of the record type it is on. " +
152
155
  "The saved filters of a view_id are checked the same way, as if you had sent them. " +
153
156
  "A VALUE the field cannot take is never dropped: a date that is neither YYYY-MM-DD nor a rolling period, and every value the field types above say is refused, refuses the whole call with a message that says what is wrong with it.";
154
157
  const SAVED_FILTER_REFUSAL = "A filter the record search would not run as written is refused here with a 400 and nothing is saved: an unknown field, an operator the field does not take (list_entries included; on a dotted path, the leaf field's), a group join other than AND/OR, or a value in the wrong shape, such as a sequence name where sequence ids go.";
155
- const DOTTED_PATH_NAMES_UNCHECKED = "On a dotted path only the operator is checked: a relation, or a linked record's field, that does not exist is neither refused nor reported, and that condition matches no record, so take every dotted path's fields from lighthouse_crm_list_attributes of the linked record type.";
158
+ const DOTTED_PATH_NAMES_UNCHECKED = "On a dotted path only the operator is checked: a relation, or a linked record's field, that does not exist is neither refused nor reported, and that condition matches no record; a dotted path's fields come from lighthouse_crm_list_attributes of the linked record type.";
156
159
  const LINK_NOT_VIEWABLE_RULE = "A record of a type you cannot view, or of a custom object you cannot open, answers 404 not_found, exactly like an id that does not exist.";
157
160
  const NOTE_CREATE_LINK_RULE = "A record of a type you cannot view answers 404 not_found, exactly like an id that does not exist, and a record of a custom object you cannot open answers 400 invalid_input, exactly like a record type that does not exist.";
158
161
  const TASK_LINKS_PER_TYPE = "Each type you send with at least one id replaces that type's links, and a type you leave out, or send with no ids, keeps them.";
@@ -162,7 +165,7 @@ export function createServer(apiKey, options = {}) {
162
165
  const ACTIVITY_RELATED_RECORDS_LEFT_OUT = "A record of a type you cannot view, or of a custom object you cannot open, is left out, as if the activity had not touched it.";
163
166
  const REPORT_ROLE_SOURCES = "notes, tasks or a meeting transcript their role does not let them read";
164
167
  const BOARD_GROUPING_RULE = "Kanban views only: the field the board's columns come from. It must be a status or select field stored on the view's records (lighthouse_crm_list_attributes gives each field's data_type), and a list's own field works only on that list's views. A field Lighthouse computes, such as tags, sequences or the first and last interaction type, cannot group a board, and neither can a text, number, date or user field. Any other key is refused with a 400 and nothing is saved, with the message: \"<field>\" cannot group this kanban view. Use a status or select field stored on its records (a list's own field works on that list's views only). Sending the view's current grouped_by back, or leaving it out, is never checked. A table view ignores it.";
165
- const SHARE_ALONG_RULE = "Pass share_along: true ONLY when the user asked to share the lists and sequences it filters on as well; a list is shared along only when the user's role can share lists, and a sequence only when it can share sequences. The response always carries shared_along ([{ type, id, name }]) and audience_cannot_open ([{ type, id, name, is_owner, is_parent }]), lists first, then sequences (type list or sequence): for the people it is now shared with, a filter on a list they cannot open is not applied, and a filter on a sequence they cannot open counts it as a sequence nobody is in.";
168
+ const SHARE_ALONG_RULE = "share_along: true is for a request to share the lists and sequences it filters on as well, and nothing is shared along without it; a list is shared along only when the user's role can share lists, and a sequence only when it can share sequences. The response always carries shared_along ([{ type, id, name }]) and audience_cannot_open ([{ type, id, name, is_owner, is_parent }]), lists first, then sequences (type list or sequence): for the people it is now shared with, a filter on a list they cannot open is not applied, and a filter on a sequence they cannot open counts it as a sequence nobody is in.";
166
169
  const shareAlongInput = (where) => `Also share the lists and sequences you own that ${where} with the same people. Nothing is shared along unless you pass true.`;
167
170
  const CRM_SORT_DESCRIPTION = 'CRM sort array. Format: [{"field":"<key>","direction":"asc"|"desc"}]. ' +
168
171
  "Multiple entries sort by the first, then tie-break by the next. " +
@@ -174,15 +177,15 @@ export function createServer(apiKey, options = {}) {
174
177
  "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. " +
175
178
  "NOT sortable (never put these in sort): multi-value fields (multi_select, signal_select, category, multi_user, multi_record), domain, email, phone, list_entries, " +
176
179
  "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. " +
177
- "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. " +
178
- "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.";
180
+ "Field keys and types come from lighthouse_crm_list_attributes; a field whose type is not in the sortable list above cannot be sorted. " +
181
+ "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. A non-empty `invalid_sort_fields` means the sort did not apply.";
179
182
  const DISCOVERY_FILTER_DESCRIPTION = "Discovery filter object. Format: " +
180
183
  '{"operator":"AND","conditions":[{"field":"<permalink>","operator":"<op>","value":<val>}],"groups":[]}. ' +
181
- "IMPORTANT: `field` MUST be one of the prefixed discovery filter keys listed for this entity at the end of this description (company_* or person_*). " +
184
+ "`field` is one of the prefixed discovery filter keys listed for this entity at the end of this description (company_* or person_*). " +
182
185
  "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. " +
183
- "For category/industry values, call lighthouse_discovery_list_industries. " +
184
- "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). " +
185
- "For tag/highlight values, call lighthouse_discovery_list_tags. " +
186
+ "Category/industry values come from lighthouse_discovery_list_industries. " +
187
+ "Location values come from the `value` field of lighthouse_discovery_list_locations: countries are ISO 3166-1 alpha-2 codes in UPPERCASE (e.g. \"US\", \"GB\", \"DE\"), regions are the region codes it returns. A lowercase code (\"us\") or a country name (\"United States\") is not a valid value. This applies to EVERY country/region filter key (company_hq_country, company_investor_country, person_country, person_work_country, and the rest). " +
188
+ "Tag/highlight values come from lighthouse_discovery_list_tags. " +
186
189
  "Fields use prefixed keys (company_name, company_headcount, person_country, etc.). " +
187
190
  "Operators by field type, " +
188
191
  "short_text: contains, is; " +
@@ -194,11 +197,11 @@ export function createServer(apiKey, options = {}) {
194
197
  "select: contains_any, not_contains_any, empty, not_empty; " +
195
198
  "multi_select: contains_any, not_contains_any, contains_all, empty, not_empty; " +
196
199
  "investor_multi_select: contains_any, not_contains_any, contains_all, empty, not_empty " +
197
- "(value is array of investor UUIDs or objects [{\"id\":\"uuid\",\"label\":\"Name\"}]. Call lighthouse_discovery_search_entities with record_type 'investor' to find investor IDs by name); " +
200
+ "(value is array of investor UUIDs or objects [{\"id\":\"uuid\",\"label\":\"Name\"}]. lighthouse_discovery_search_entities with record_type 'investor' finds investor IDs by name); " +
198
201
  "work_experience_multi_select: contains_any, not_contains_any, contains_all, empty, not_empty " +
199
- "(value is array of organisation UUIDs or objects [{\"id\":\"uuid\",\"label\":\"Name\"}]. Call lighthouse_discovery_search_entities with record_type 'company' to find organisation IDs by name); " +
202
+ "(value is array of organisation UUIDs or objects [{\"id\":\"uuid\",\"label\":\"Name\"}]. lighthouse_discovery_search_entities with record_type 'company' finds organisation IDs by name); " +
200
203
  "education_multi_select: contains_any, not_contains_any, contains_all, empty, not_empty " +
201
- "(value is array of institution UUIDs or objects [{\"id\":\"uuid\",\"label\":\"Name\"}]. Call lighthouse_discovery_search_entities with record_type 'school' to find school IDs by name); " +
204
+ "(value is array of institution UUIDs or objects [{\"id\":\"uuid\",\"label\":\"Name\"}]. lighthouse_discovery_search_entities with record_type 'school' finds school IDs by name); " +
202
205
  "funding_type_multi_select: contains_any, not_contains_any, empty, not_empty; " +
203
206
  "multi_user: contains_any, not_contains_any, contains_all, empty, not_empty (value is array of user UUIDs); " +
204
207
  "boolean/checkbox: eq; " +
@@ -216,22 +219,21 @@ export function createServer(apiKey, options = {}) {
216
219
  "date ops → ISO date string \"YYYY-MM-DD\". " +
217
220
  "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. " +
218
221
  "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. " +
219
- "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 unrecognised 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. " +
220
- "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. " +
221
- "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. " +
222
- "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): " +
222
+ "The company_*/person_* keys listed below are matched exactly, with no variants (no _usd suffix, no abbreviation like hq for headquarter), and the bare permalinks returned by lighthouse_discovery_list_attributes are not filter keys; any unrecognised key is dropped (the search runs as if that condition were absent) and echoed in the response's `ignored_filter_fields`, the one place a mistyped key shows, because the result otherwise looks fine but is unfiltered. " +
223
+ "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). " +
224
+ "CRM attribute keys do not apply here. The complete set of valid discovery filter keys for this entity is listed at the end of this description. " +
225
+ "RESPONSE VALIDATION META: this search does not fail on a bad filter; instead the response carries, alongside `items`/`total`, three arrays (collected recursively across nested groups): " +
223
226
  "`ignored_filter_fields` (keys not recognised; that condition was dropped), " +
224
227
  "`invalid_operators` ([{field, operator}] where the operator is not valid for that field's type; that condition was dropped), and " +
225
228
  "`invalid_logical_operators` ([{operator}] where a group's join was not AND/OR; treated as AND). " +
226
- "If ANY are non-empty, part of your filter did NOT apply (results are broader than intended): fix the offending field/operator and retry, or tell the user which clauses could not be applied. A `warnings` array summarises the same in plain text.";
229
+ "A non-empty array means part of the filter did NOT apply, so the results are broader than it asked for. A `warnings` array summarises the same in plain text.";
227
230
  const COMPANY_DISCOVERY_SORT_DESCRIPTION = 'Discovery company sort. Format: {"by":"<field>","dir":"asc"|"desc"}. ' +
228
231
  "ONLY two fields are sortable: founded_on (founding date) and total_funding_amount_usd (total funding raised, in USD). " +
229
232
  "Omit sort to use the default: relevance score, descending. " +
230
233
  "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. " +
231
- "If the user asks to sort companies by anything else, tell them only founding date and total funding are supported. " +
232
234
  "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.";
233
- 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.";
234
- const COMPANY_DISCOVERY_FIELDS = " COMPANY FILTER KEYS (use EXACTLY these as the condition `field`; any other key is ignored): " +
235
+ 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. No per-row search of the CRM is needed: \"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.";
236
+ const COMPANY_DISCOVERY_FIELDS = " COMPANY FILTER KEYS (the values the condition `field` takes; any other key is ignored): " +
235
237
  "Basics: company_name (short_text), company_domain (url), company_description (long_text), company_headcount (number), company_founded_on (date, year), company_created_at (date). " +
236
238
  "Industry: company_categories (multi_select; values from lighthouse_discovery_list_industries → categories), company_sdg_impact (multi_select; values from list_industries → sdg_categories). " +
237
239
  "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). " +
@@ -246,7 +248,7 @@ export function createServer(apiKey, options = {}) {
246
248
  'NESTED-GROUP EXAMPLE for "headcount 10-50 AND (HQ in US or UK) AND raised Series A": ' +
247
249
  '{"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":[]}]} ' +
248
250
  "(country codes from list_locations, funding-type values from lighthouse_discovery_list_funding_types).";
249
- const PERSON_DISCOVERY_FIELDS = " PERSON FILTER KEYS (use EXACTLY these as the condition `field`; any other key is ignored): " +
251
+ const PERSON_DISCOVERY_FIELDS = " PERSON FILTER KEYS (the values the condition `field` takes; any other key is ignored): " +
250
252
  "Basics: person_name (short_text), person_created_at (date). " +
251
253
  "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). " +
252
254
  "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). " +
@@ -255,7 +257,7 @@ export function createServer(apiKey, options = {}) {
255
257
  "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). " +
256
258
  "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). " +
257
259
  "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). " +
258
- "Stealth: person_stealth_start_date, person_stealth_end_date (date), person_stealth_predicted_area (multi_select; the predicted area a stealth founder is building in, values from lighthouse_discovery_list_industries → stealth_predicted_areas, the same list as stealth_predicted_areas of person_signals; it matches everyone the classifier gave that area, while stealth_predicted_areas inside person_signals also requires the stealth signal and takes its since date, which is how the app filters, so prefer that form). " +
260
+ "Stealth: person_stealth_start_date, person_stealth_end_date (date), person_stealth_predicted_area (multi_select; the predicted area a stealth founder is building in, values from lighthouse_discovery_list_industries → stealth_predicted_areas, the same list as stealth_predicted_areas of person_signals; it matches everyone the classifier gave that area, while stealth_predicted_areas inside person_signals also requires the stealth signal and takes its since date, which is how the app filters). " +
259
261
  "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). " +
260
262
  "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). " +
261
263
  'NESTED-GROUP EXAMPLE for "(based in Germany or France) AND a technical or AI/data background": ' +
@@ -266,11 +268,11 @@ export function createServer(apiKey, options = {}) {
266
268
  annotations: READ_ONLY,
267
269
  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, source_field and list_id. " +
268
270
  "`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. " +
269
- "ALWAYS call this before building filters for search_records / list_records or creating/updating views. Use the exact permalinks it returns. " +
270
- "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. " +
271
+ "WHEN TO USE: building filters for search_records / list_records or creating/updating views, which take the exact permalinks it returns. " +
272
+ "To keep the response small it does NOT include select/multi_select option values: when a field has has_options=true, lighthouse_crm_list_options with that permalink returns its allowed values. " +
271
273
  "Supports built-in types (company, person, deal, task) and custom objects (use the object's permalink, e.g. 'lp', 'project'). " +
272
274
  "When called without record_type, returns all types including custom objects as top-level keys. " +
273
- "A custom field that belongs to a single list carries that list's list_id: it can be set only on a record already in that list (add the record with lighthouse_crm_add_to_list first), and its value sits in that list's entry under the record's list_entries. Without list_id every list's fields are listed; passing list_id keeps the fields of every record plus that list's own. A list you cannot open answers 'List not found', and so does filtering or sorting on a field of such a list.",
275
+ "A custom field that belongs to a single list carries that list's list_id: it can be set only on a record already in that list (lighthouse_crm_add_to_list adds it), and its value sits in that list's entry under the record's list_entries. Without list_id every list's fields are listed; passing list_id keeps the fields of every record plus that list's own. A list you cannot open answers 'List not found', and so does filtering or sorting on a field of such a list.",
274
276
  inputSchema: {
275
277
  record_type: z
276
278
  .string()
@@ -422,8 +424,8 @@ export function createServer(apiKey, options = {}) {
422
424
  server.registerTool("lighthouse_crm_list_options", {
423
425
  title: "List CRM Field Options",
424
426
  annotations: READ_ONLY,
425
- 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. " +
426
- "Call this when lighthouse_crm_list_attributes shows has_options=true for a field and you need its values before filtering or setting it. " +
427
+ 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 not listed, and a write naming it is refused. " +
428
+ "WHEN TO USE: lighthouse_crm_list_attributes shows has_options=true for a field and its values are needed for filtering or setting it. " +
427
429
  "Results are paginated (max 100 per page): use `search` to narrow by label, and `limit` / `offset` to page through a field with many options. " +
428
430
  "For custom objects, use the object's permalink as record_type.",
429
431
  inputSchema: {
@@ -448,7 +450,7 @@ export function createServer(apiKey, options = {}) {
448
450
  annotations: CREATES,
449
451
  description: "Add a new option to a select/multi_select field. The option value is auto-generated from the label. " +
450
452
  "Re-creating a system default option that was previously removed restores it (its original color and label are kept). " +
451
- "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. " +
453
+ "Returns the updated list of ALL options for the field, each row { value, label, color, priority, is_default, enabled }, without a built-in value the workspace switched off, plus meta.total. The list comes back in the order the field offers its options, so the first row is not necessarily the option just created: its label is what identifies it. " +
452
454
  "For custom objects, use the object's permalink as record_type. " +
453
455
  "Options can only be created on company, person, deal, and custom objects.",
454
456
  inputSchema: {
@@ -492,12 +494,11 @@ export function createServer(apiKey, options = {}) {
492
494
  description: "Search and filter records in YOUR CRM workspace (companies, people, or deals you've added). " +
493
495
  "Supports filters, sorting, pagination, and saved views. " +
494
496
  "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). " +
495
- "NEVER guess field keys, ALWAYS call lighthouse_crm_list_attributes first to get the exact field keys " +
496
- "for the record_type, then use those keys in your filter conditions. " +
497
- "The response's `meta` includes `total` (an integer match count), `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), `invalid_operators` (a condition whose operator its field does not take, left out), `invalid_logical_operators` (a group joined by something other than AND/OR, read as AND) and `invalid_sort_fields`. Check every array to confirm the whole filter and the sort were applied. " +
497
+ "The exact field keys for the record_type come from lighthouse_crm_list_attributes. " +
498
+ "The response's `meta` includes `total` (an integer match count), `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), `invalid_operators` (a condition whose operator its field does not take, left out), `invalid_logical_operators` (a group joined by something other than AND/OR, read as AND) and `invalid_sort_fields`; empty arrays mean the whole filter and the sort were applied. " +
498
499
  DOTTED_PATH_NAMES_UNCHECKED + " " +
499
500
  "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. " +
500
- "NOT for discovering new companies/people, use lighthouse_discovery_* tools for that.",
501
+ "NOT for discovering new companies/people: the lighthouse_discovery_* tools cover that.",
501
502
  inputSchema: {
502
503
  record_type: z.string().describe("Record type (company, person, deal, or custom object permalink)"),
503
504
  filters: z
@@ -562,14 +563,14 @@ export function createServer(apiKey, options = {}) {
562
563
  server.registerTool("lighthouse_crm_list_record_activity", {
563
564
  title: "List Record Activity",
564
565
  annotations: READ_ONLY,
565
- 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. " +
566
+ description: "Read the activity feed of one CRM record, newest first: who changed what, when, and from where. WHEN TO USE: questions about history (\"what changed on this deal last week\", \"who moved it to won\", \"which fields has nobody touched\"), which the record's current values cannot answer. " +
566
567
  "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. " +
567
568
  "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 / scout / 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). " +
568
569
  "changes[] holds the field edits: { field, label, data_type, is_custom, list, previous, current }, where previous and current are each { value, display }: value is what is stored (an option value, a uuid, a number) and display is the same thing resolved for humans (an option's label, a linked record or user object). Consecutive edits to the same field by the same person on the same day are collapsed into one entry, so previous is the value before the burst and current the value after it. " +
569
570
  "Every entry has id, activity_id, action, occurred_at, summary, actor, source, target and changes. One save that changed several fields is reported as one entry per field: id stays unique per entry, activity_id is shared by the entries from that same save. The five detail keys are on every entry and null on the entries they do not describe: note and task carry that note's / task's body inline (no follow-up call needed), merge carries { merged_record, kept_record }, document carries { type, id, name } for an attached file or folder, and related_records (an empty array when there are none) lists the OTHER records when one activity spanned several (never the entry's own target, which is already under target, and never the record whose feed this is). " +
570
571
  ACTIVITY_RELATED_RECORDS_LEFT_OUT + " " +
571
572
  "record_type is company, person, deal, or a custom object permalink; pass task or note to read a task's or note's own history. " +
572
- "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.",
573
+ "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 it is not a complete audit trail.",
573
574
  inputSchema: {
574
575
  record_type: z.string().describe("Record type (company, person, deal, a custom object permalink, or task / note)"),
575
576
  record_id: z.string().uuid().optional().describe("Record ID whose feed to read"),
@@ -591,8 +592,8 @@ export function createServer(apiKey, options = {}) {
591
592
  title: "List AI Reports on a Record",
592
593
  annotations: READ_ONLY,
593
594
  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. " +
594
- "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. " +
595
- "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. " +
595
+ "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\" (this shows whether one exists before another is written from scratch). Not for dashboards or charts (those are lighthouse_reports_*), and nothing here generates a report: that happens in the app. " +
596
+ "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, which lighthouse_crm_get_ai_report returns. " +
596
597
  "A report quoting a mailbox, calendar, meeting notes, LinkedIn account or file the API key's user cannot read, or " + REPORT_ROLE_SOURCES + ", is left out of the list (its author always sees it).",
597
598
  inputSchema: {
598
599
  record_type: z.string().describe("Record type: company, person, deal, or a custom object permalink"),
@@ -615,7 +616,7 @@ export function createServer(apiKey, options = {}) {
615
616
  title: "Get AI Report",
616
617
  annotations: READ_ONLY,
617
618
  description: "One AI report by id (from lighthouse_crm_list_ai_reports): the same metadata plus record_type / record_id and content, the full document as Markdown with its section headings (citation markers removed). " +
618
- "WHEN TO USE: the user wants what an earlier memo or report actually says (a section, a number, a recommendation), or a comparison with it. A report can be long: quote or summarise the part the question is about rather than echoing it whole. " +
619
+ "WHEN TO USE: the user wants what an earlier memo or report actually says (a section, a number, a recommendation), or a comparison with it. A report can be long. " +
619
620
  "content is null while the report is still processing or failed. Answers 403 naming the channel when the report draws on a shared mailbox, calendar, meeting notes, LinkedIn account or document the API key's user cannot read, or on " + REPORT_ROLE_SOURCES + " (its author is never refused for its sources), and 404 for an unknown id.",
620
621
  inputSchema: {
621
622
  id: z.string().uuid().describe("Report ID from lighthouse_crm_list_ai_reports"),
@@ -634,20 +635,23 @@ export function createServer(apiKey, options = {}) {
634
635
  annotations: CREATES,
635
636
  description: "Create a new CRM record. Pass all fields (standard + custom) in a single flat data object, " +
636
637
  "the API splits standard vs custom fields automatically. " +
637
- "BEFORE calling this tool, call lighthouse_crm_list_attributes to discover available field keys and types for the record_type. " +
638
+ "Field keys and types for the record_type come from lighthouse_crm_list_attributes. " +
638
639
  "A name is strongly recommended for every record: company → name (string). person → fn or first_name (string). deal → name (string). " +
639
640
  "custom objects → name (string) is the only standard field; all other fields are custom. " +
640
641
  "If you omit the name, the record is still created but stored with the name \"Unnamed\", so always provide one when you can. " +
641
- "IMPORTANT data format rules (company/person/deal ONLY, custom objects do NOT have relation fields): " +
642
- "domain (company) → ALWAYS pass as array of strings, even for one domain: [\"acme.com\"]. " +
642
+ "Data format rules (company/person/deal ONLY, custom objects do NOT have relation fields): " +
643
+ "domain (company) → an array of strings, even for one domain: [\"acme.com\"]. " +
643
644
  "associated_people (company) → array of person UUIDs (or {id} objects). " +
644
645
  "associated_deals (company) → array of deal UUIDs. " +
645
646
  "emails (person) → array of email strings: [\"john@acme.com\"]. " +
646
- "phones (person) → array of objects: [{\"prefix\":\"+1\",\"number\":\"5551234\"}]. " +
647
+ "phones (person) → array of objects: [{\"prefix\":1,\"number\":\"5551234\"}]. " +
647
648
  "associated_companies (person/deal) → array of company UUIDs. " +
648
649
  "associated_people (deal) → array of person UUIDs. " +
649
650
  "owner (deal) → UUID string of the owner user. " +
650
- "Custom fields use their field permalink as the key. A custom rating field's value is an integer 1-5 (or null to clear). " +
651
+ "Custom fields use their field permalink as the key. " +
652
+ "A custom email, phone, multi_select, multi_record or multi_user field holds a list on every record type, custom objects included: always pass an array, even for one value. " +
653
+ "A custom email field → [\"jane@acme.com\"]. A custom phone field → [{\"prefix\":1,\"number\":\"5551234\"}] or \"+1 555 1234\"; a phone needs a country code. " +
654
+ "A custom rating field's value is a number from 0 to 5 (or null to clear). " +
651
655
  "The response returns all fields as top-level keys (custom fields are NOT nested under an attributes object).",
652
656
  inputSchema: {
653
657
  record_type: z.string().describe("Record type (company, person, deal, or custom object permalink)"),
@@ -669,22 +673,23 @@ export function createServer(apiKey, options = {}) {
669
673
  annotations: DESTRUCTIVE,
670
674
  description: "Update an existing CRM record. Only include the fields you want to change. " +
671
675
  "Standard and custom fields can be mixed in a single flat data object, the API splits them automatically. " +
672
- "BEFORE calling this tool, call lighthouse_crm_list_attributes to discover available field keys and types. " +
673
- "Relation and multi-value fields (domain, associated_people, associated_deals, emails, phones, associated_companies, and custom multi_select/multi_record/multi_user) " +
674
- "support two modes (company/person/deal ONLY; custom objects have only 'name' and custom fields): " +
675
- "(1) pass a bare ARRAY to REPLACE the whole field, e.g. domain: [\"acme.com\"], or an empty array [] to clear it (remove all); " +
676
+ "Field keys and types come from lighthouse_crm_list_attributes. " +
677
+ "Relation and multi-value fields (domain, associated_people, associated_deals, emails, phones, associated_companies, and custom email/phone/multi_select/multi_record/multi_user) " +
678
+ "support these modes (custom objects have only 'name' and custom fields, so the custom ones apply to them): " +
679
+ "(1) pass a bare ARRAY to REPLACE the whole field, e.g. domain: [\"acme.com\"], or an empty array [] to clear it (remove all); one value on its own is refused, so one domain is still an array; " +
676
680
  "(2) pass { \"add\": [...] } and/or { \"remove\": [...] } to append or remove items WITHOUT fetching first, " +
677
- "e.g. domain: { \"add\": [\"new.com\"] } appends one domain, associated_people: { \"remove\": [\"<uuid>\"] } removes one member. " +
678
- "Use add/remove to avoid a get-then-merge round-trip; it is also the only safe way to change one relation member without disturbing the others. " +
681
+ "e.g. domain: { \"add\": [\"new.com\"] } appends one domain, associated_people: { \"remove\": [\"<uuid>\"] } removes one member, a custom email field: { \"add\": [\"jane@acme.com\"] } appends one address. " +
682
+ "add/remove avoids a get-then-merge round-trip; it is also the only safe way to change one relation member without disturbing the others. " +
679
683
  "Field formats: " +
680
684
  "domain (company) → array of strings: [\"acme.com\"]. " +
681
685
  "associated_people (company) → array of person UUIDs. " +
682
686
  "emails (person) → array of email strings. " +
683
- "phones (person) → array of objects: [{\"prefix\":\"+1\",\"number\":\"5551234\"}]. " +
687
+ "phones (person) and a custom phone field → array of objects: [{\"prefix\":1,\"number\":\"5551234\"}]; a phone needs a country code. " +
688
+ "A custom email field → array of email strings. " +
684
689
  "associated_companies (person/deal) → array of company UUIDs. " +
685
690
  "associated_people (deal) → array of person UUIDs. " +
686
691
  "associated_deals (company/person) → array of deal UUIDs. " +
687
- "A custom rating field's value → an integer 1-5 (or null to clear). " +
692
+ "A custom rating field's value → a number from 0 to 5 (or null to clear). " +
688
693
  "The response returns all fields as top-level keys (custom fields are NOT nested under an attributes object).",
689
694
  inputSchema: {
690
695
  record_type: z.string().describe("Record type (company, person, deal, or custom object permalink)"),
@@ -752,7 +757,7 @@ export function createServer(apiKey, options = {}) {
752
757
  annotations: CREATES,
753
758
  description: "Create up to 100 CRM records of the same record_type in one call. " +
754
759
  "Each item in `records` is a flat data object, the SAME shape you'd pass as `data` to lighthouse_crm_create_record. " +
755
- "BEFORE calling, call lighthouse_crm_list_attributes to discover field keys and types. " +
760
+ "Field keys and types come from lighthouse_crm_list_attributes. " +
756
761
  "Partial success: the response returns a `results` array (one entry per item, in order, with success/data/error) and a `summary` " +
757
762
  "({total, succeeded, failed}). A failed item does NOT roll back the others. " +
758
763
  "Same per-record field rules as lighthouse_crm_create_record (domain as array, emails as array, owner as UUID, etc.).",
@@ -853,9 +858,9 @@ export function createServer(apiKey, options = {}) {
853
858
  server.registerTool("lighthouse_crm_create_list", {
854
859
  title: "Create CRM List",
855
860
  annotations: CREATES,
856
- description: "Create a new CRM list to organize records. Supports companies, people, deals, and custom objects. " +
861
+ description: "Create a new CRM list to organise records. Supports companies, people, deals, and custom objects. " +
857
862
  "Defaults to 'private' visibility. " +
858
- "To share with specific teams after creation, use lighthouse_crm_share_list_with_teams. " +
863
+ "lighthouse_crm_share_list_with_teams shares a list with specific teams after creation. " +
859
864
  "Requires lists.edit permission.",
860
865
  inputSchema: {
861
866
  name: z.string().describe("List name"),
@@ -953,8 +958,8 @@ export function createServer(apiKey, options = {}) {
953
958
  title: "List Records in a List",
954
959
  annotations: READ_ONLY,
955
960
  description: "Get records in a CRM list with optional filtering, sorting, and view support. " +
956
- "Same filter format as lighthouse_crm_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. " +
957
- "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), `invalid_operators` (a condition whose operator its field does not take, left out of the results), `invalid_logical_operators` (a group joined by something other than AND/OR, read as AND) and `invalid_sort_fields` (a sort this list cannot apply, reported rather than refused, so the records come back in their default order). The saved filters of a view_id are reported on the same way. Check every array to confirm the whole filter and the sort were applied; a value in the wrong shape is refused instead, with a 400. " +
961
+ "Same filter format as lighthouse_crm_search_records. Valid filter keys come from lighthouse_crm_list_attributes with this list's record_type, which is 'company', 'person', 'deal', or a custom object permalink. " +
962
+ "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), `invalid_operators` (a condition whose operator its field does not take, left out of the results), `invalid_logical_operators` (a group joined by something other than AND/OR, read as AND) and `invalid_sort_fields` (a sort this list cannot apply, reported rather than refused, so the records come back in their default order). The saved filters of a view_id are reported on the same way. Empty arrays mean the whole filter and the sort were applied; a value in the wrong shape is refused instead, with a 400. " +
958
963
  DOTTED_PATH_NAMES_UNCHECKED + " " +
959
964
  "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.",
960
965
  inputSchema: {
@@ -1053,7 +1058,7 @@ export function createServer(apiKey, options = {}) {
1053
1058
  title: "Share List with Teams",
1054
1059
  annotations: ADDS_IF_MISSING,
1055
1060
  description: "Grant team-level access to a CRM list. Only the list owner can share. " +
1056
- "Use lighthouse_workspace_list_teams to find team IDs first. " +
1061
+ "Team IDs come from lighthouse_workspace_list_teams. " +
1057
1062
  "Requires lists.share permission. " +
1058
1063
  "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.",
1059
1064
  inputSchema: {
@@ -1169,15 +1174,12 @@ export function createServer(apiKey, options = {}) {
1169
1174
  annotations: CREATES,
1170
1175
  description: "Create a note, optionally linked to one or more CRM records. Records are optional: omit them to create a standalone note that still appears in the workspace Notes section. " +
1171
1176
  "Link records via the `records` param, keyed by type. " +
1172
- "The company/person/deal ids you pass MUST be EXISTING CRM record ids (the ids returned by lighthouse_crm_* tools), NOT discovery ids from lighthouse_discovery_* search results. To note on a discovery result, first add it to your CRM and use the resulting CRM record id. " +
1177
+ "The company/person/deal ids are EXISTING CRM record ids (the ids returned by lighthouse_crm_* tools), NOT discovery ids from lighthouse_discovery_* search results; a discovery result has to be in the CRM before a note can link it. " +
1173
1178
  "Content is HTML; plain text works too. " +
1174
- "To @mention/tag workspace users: " +
1175
- "1) Call lighthouse_workspace_list_users first to find the user's ID, " +
1176
- "2) Include their user IDs in tagged_users, " +
1177
- "3) Include a mention span in the HTML content: " +
1179
+ "A @mention/tag of a workspace user takes their user ID (from lighthouse_workspace_list_users) in tagged_users and a mention span in the HTML content: " +
1178
1180
  '<span data-type="mention" data-id="USER_UUID" data-label="First Last">@First Last</span>. ' +
1179
1181
  "Tagged users receive an in-app notification. " +
1180
- "Optionally attach workspace tags by permalink via `tags` (list the available tags with lighthouse_crm_list_note_tags); these are labeled, colored tags shared across the workspace and are separate from tagged_users. " +
1182
+ "Optionally attach workspace tags by permalink via `tags` (lighthouse_crm_list_note_tags lists the available tags); these are labeled, colored tags shared across the workspace and are separate from tagged_users. " +
1181
1183
  "This always creates a standard note (type 'standard', meeting null): there is no type input (one sent in the request is ignored), and meeting notes are written by the Notetaker from a recording, never through this tool.",
1182
1184
  inputSchema: {
1183
1185
  records: z
@@ -1228,14 +1230,14 @@ export function createServer(apiKey, options = {}) {
1228
1230
  title: "Update Note",
1229
1231
  annotations: DESTRUCTIVE,
1230
1232
  description: "Change an existing note's title, content, tags or mentions by note ID. Pass only what changes: a key that is absent is left untouched. " +
1231
- "content REPLACES the whole body: to ADD to a note, read it with lighthouse_crm_get_note first and pass the full new content with the addition, never the addition alone (anything you leave out, an existing @mention span included, is gone). " +
1233
+ "content REPLACES the whole body: anything left out, an existing @mention span included, is gone, so an addition to a note is the full current content (from lighthouse_crm_get_note) plus the new part. " +
1232
1234
  "Content is HTML, the same format lighthouse_crm_create_note takes: <p> for paragraphs, <ul><li> for bullets, <h2> for a heading, <strong> for emphasis; plain text works too and is stored as one paragraph. " +
1233
- "To @mention/tag workspace users: 1) call lighthouse_workspace_list_users to find the user's ID, 2) include their user IDs in tagged_users, 3) include a mention span in the HTML content: " +
1235
+ "A @mention/tag of a workspace user takes their user ID (from lighthouse_workspace_list_users) in tagged_users and a mention span in the HTML content: " +
1234
1236
  '<span data-type="mention" data-id="USER_UUID" data-label="First Last">@First Last</span>. ' +
1235
- "A user newly named in tagged_users receives one in-app notification; keep the spans of people already mentioned when you resend the content. " +
1236
- "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. " +
1237
+ "A user newly named in tagged_users receives one in-app notification; the spans of people already mentioned stay only if the content sent still has them. " +
1238
+ "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); lighthouse_crm_manage_note_tags adds or removes single tags without touching the rest. " +
1237
1239
  "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. " +
1238
- "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.",
1240
+ "Returns the updated note in the same shape as lighthouse_crm_get_note. lighthouse_crm_link_note_record / lighthouse_crm_unlink_note_record change which records a note is attached to, and lighthouse_crm_delete_note removes it.",
1239
1241
  inputSchema: {
1240
1242
  note_id: z.string().uuid().optional().describe("Note ID"),
1241
1243
  id: z.string().uuid().optional().describe("Same as note_id, the name this tool has always taken. Send either one."),
@@ -1274,9 +1276,9 @@ export function createServer(apiKey, options = {}) {
1274
1276
  title: "Link Record to Note",
1275
1277
  annotations: ADDS_IF_MISSING,
1276
1278
  description: "Attach a record to an existing note. Call once per record. " +
1277
- "The company/person/deal record_id MUST be an EXISTING CRM record id (an id returned by lighthouse_crm_* tools), NOT a discovery id from lighthouse_discovery_* search results. To link a discovery result, first add it to your CRM and use the resulting CRM record id. " +
1279
+ "The company/person/deal record_id is an EXISTING CRM record id (an id returned by lighthouse_crm_* tools), NOT a discovery id from lighthouse_discovery_* search results; a discovery result has to be in the CRM before it can be linked. " +
1278
1280
  LINK_NOT_VIEWABLE_RULE + " " +
1279
- "Use lighthouse_crm_get_note to see the current set of linked records (the ones you can view).",
1281
+ "lighthouse_crm_get_note shows the current set of linked records (the ones you can view).",
1280
1282
  inputSchema: {
1281
1283
  note_id: z.string().uuid().describe("Note ID"),
1282
1284
  record_type: z.string().describe("Record type: company, person, deal, or a custom object permalink"),
@@ -1416,7 +1418,7 @@ export function createServer(apiKey, options = {}) {
1416
1418
  sort: z.array(z.record(z.any())).optional().describe('Task sort array: [{"field":"<key>","direction":"asc"|"desc"}], applied in order (default: due_date asc). ' +
1417
1419
  "Sortable task fields are title, status, priority, due_date, created_at, created_by, time_in_status (asc = fewest days in the current status first). Tasks have no custom fields. " +
1418
1420
  "Multi-value fields are NOT sortable (assigned_to is multi_user; records and multi_select fields can't be sorted). " +
1419
- 'An unknown or unsortable sort field is rejected with a clear error (field "x" is not sortable), so pick a sortable field. ' +
1421
+ 'An unknown or unsortable sort field is rejected with a clear error (field "x" is not sortable). ' +
1420
1422
  "status and priority sort by their position in the field's option list, not alphabetically. Priority positions are its magnitude, so ascending on priority is least urgent first and descending puts the urgent work on top."),
1421
1423
  limit: z.number().min(1).max(100).default(25),
1422
1424
  offset: z.number().min(0).default(0),
@@ -1451,12 +1453,12 @@ export function createServer(apiKey, options = {}) {
1451
1453
  title: "Create Task",
1452
1454
  annotations: CREATES,
1453
1455
  description: "Create a CRM task, optionally linked to records and assigned to users. " +
1454
- "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`. " +
1456
+ "Status values are configured per workspace and are NOT fixed: lighthouse_crm_list_options with record_type 'task' and permalink 'status' returns the allowed values, and `status` takes the option's value (not its label). " +
1455
1457
  "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.",
1456
1458
  inputSchema: {
1457
1459
  title: z.string().describe("Task title"),
1458
1460
  description: z.string().optional().describe("Task description"),
1459
- status: z.string().optional().describe("Task status. Use the option value (not the label) from lighthouse_crm_list_options (record_type 'task', permalink 'status'); valid values are workspace-specific, do not assume any fixed set."),
1461
+ status: z.string().optional().describe("Task status: the option value (not the label) from lighthouse_crm_list_options (record_type 'task', permalink 'status'). Valid values are workspace-specific."),
1460
1462
  priority: z.string().nullable().optional().describe("Priority option value from lighthouse_crm_list_options (record_type 'task', permalink 'priority'): urgent, high, medium, low or very_low unless this workspace configured its own. Speak to the user in the label, never the value. Send null to clear it."),
1461
1463
  due_date: z.string().optional().describe("Due date (ISO 8601)"),
1462
1464
  assigned_to: z
@@ -1482,13 +1484,13 @@ export function createServer(apiKey, options = {}) {
1482
1484
  annotations: DESTRUCTIVE,
1483
1485
  description: "Update a CRM task. Provide only fields you want to change. " +
1484
1486
  "assigned_to replaces all current assignees. In records, " + TASK_LINKS_PER_TYPE.charAt(0).toLowerCase() + TASK_LINKS_PER_TYPE.slice(1) + " " +
1485
- "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`. " +
1487
+ "Status values are configured per workspace and are NOT fixed: lighthouse_crm_list_options with record_type 'task' and permalink 'status' returns the allowed values, and `status` takes the option's value (not its label). " +
1486
1488
  "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.",
1487
1489
  inputSchema: {
1488
1490
  id: z.string().uuid().describe("Task ID"),
1489
1491
  title: z.string().optional(),
1490
1492
  description: z.string().optional(),
1491
- status: z.string().optional().describe("Task status. Use the option value (not the label) from lighthouse_crm_list_options (record_type 'task', permalink 'status'); valid values are workspace-specific, do not assume any fixed set."),
1493
+ status: z.string().optional().describe("Task status: the option value (not the label) from lighthouse_crm_list_options (record_type 'task', permalink 'status'). Valid values are workspace-specific."),
1492
1494
  priority: z.string().nullable().optional().describe("Priority option value from lighthouse_crm_list_options (record_type 'task', permalink 'priority'): urgent, high, medium, low or very_low unless this workspace configured its own. Speak to the user in the label, never the value. Send null to clear it."),
1493
1495
  due_date: z.string().optional(),
1494
1496
  assigned_to: z
@@ -1536,7 +1538,8 @@ export function createServer(apiKey, options = {}) {
1536
1538
  "Each row includes my_role ('owner' | 'editor' | 'viewer'), is_owner, " +
1537
1539
  "owner (the member who owns it: { id, first_name, last_name, full_name, avatar_url, email }), " +
1538
1540
  "sharing ('private' or 'workspace'), linked_records_count, and " +
1539
- "shared_with_teams[] (each entry has { team_id, team_name, role, granted_at }).",
1541
+ "shared_with_teams[] (each entry has { team_id, team_name, role, granted_at }). " +
1542
+ `${FILE_BLOCKED_KEYS} ${FILE_BLOCKED_TOOL_RULE}`,
1540
1543
  inputSchema: {
1541
1544
  folder_id: z.string().uuid().optional().describe("Folder UUID to list contents of (omit for workspace root)"),
1542
1545
  limit: z.number().int().min(1).max(500).optional().describe("Rows per sub-list (folders, files). Default 200, max 500"),
@@ -1580,7 +1583,7 @@ export function createServer(apiKey, options = {}) {
1580
1583
  annotations: READ_ONLY,
1581
1584
  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. " +
1582
1585
  "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. " +
1583
- "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.",
1586
+ "lighthouse_documents_list with this folder_id lists what is INSIDE it. A folder you cannot open answers the same not found as one that does not exist.",
1584
1587
  inputSchema: {
1585
1588
  folder_id: z.string().uuid().optional().describe("Folder UUID. The name every other folder tool takes."),
1586
1589
  id: z.string().uuid().optional().describe("Same as folder_id, the name the tool this one is named after would take. Send either one."),
@@ -1679,10 +1682,10 @@ export function createServer(apiKey, options = {}) {
1679
1682
  server.registerTool("lighthouse_documents_upload_file", {
1680
1683
  title: "Upload File",
1681
1684
  annotations: CREATES,
1682
- description: "Reserve a file row and get a signed upload URL: " +
1683
- "1) call this; 2) POST the file to upload_url as multipart/form-data, first append every key/value " +
1684
- "from the returned upload_fields, then append the file last under the field name 'file'. " +
1685
- "Do not set an Authorization header. Max 200MB, enforced by storage (oversized uploads are rejected). " +
1685
+ description: "Reserve a file row and get a signed upload URL. " +
1686
+ "The upload is a POST of the file to upload_url as multipart/form-data: every key/value " +
1687
+ "from the returned upload_fields first, then the file last under the field name 'file'. " +
1688
+ "The upload takes no Authorization header. Max 200MB, enforced by storage (oversized uploads are rejected). " +
1686
1689
  "The file is confirmed and sized automatically the first time you read it " +
1687
1690
  "(lighthouse_documents_get_file or a folder listing), no separate finalize step. " +
1688
1691
  "The URL expires in 30 minutes.",
@@ -1707,7 +1710,8 @@ export function createServer(apiKey, options = {}) {
1707
1710
  server.registerTool("lighthouse_documents_get_file", {
1708
1711
  title: "Get File",
1709
1712
  annotations: READ_ONLY,
1710
- 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). To read what the file SAYS, use lighthouse_documents_get_file_content.",
1713
+ 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). lighthouse_documents_get_file_content reads what the file SAYS. " +
1714
+ `${FILE_BLOCKED_KEYS} ${FILE_BLOCKED_TOOL_RULE} ${FILE_BLOCKED_ANSWER}`,
1711
1715
  inputSchema: {
1712
1716
  file_id: z.string().uuid().describe("File UUID"),
1713
1717
  download_url: z.boolean().optional().describe("Include a short-lived signed download URL"),
@@ -1730,7 +1734,8 @@ export function createServer(apiKey, options = {}) {
1730
1734
  "Take the file id from lighthouse_documents_list or lighthouse_documents_list_for_record. " +
1731
1735
  "Returns the file's row (the one lighthouse_documents_get_file returns: id, name, folder_id, size_bytes, content_type, sharing, owner, ...) followed by kind, pages_total, text, reason and note. " +
1732
1736
  FILE_CONTENT_RULES +
1733
- " A file the user may not open answers forbidden, one that does not exist not found.",
1737
+ " A file the user may not open answers forbidden, one that does not exist not found. " +
1738
+ `${FILE_BLOCKED_TOOL_RULE} ${FILE_BLOCKED_ANSWER}`,
1734
1739
  inputSchema: {
1735
1740
  file_id: z.string().uuid().optional().describe("File UUID. The name every other file tool takes."),
1736
1741
  id: z.string().uuid().optional().describe("Same as file_id. Send either one."),
@@ -1761,7 +1766,8 @@ export function createServer(apiKey, options = {}) {
1761
1766
  annotations: DESTRUCTIVE,
1762
1767
  description: "Rename, move, or change sharing on a file. Editors can rename and delete it. Only the owner can move it (into another folder or to the root), change sharing, or grant team access, with one exception: the owner of the folder the file is in can also move it down into a sub-folder of that folder, when they own that sub-folder and every folder in between. " +
1763
1768
  "team_ids replaces the current set when supplied. " +
1764
- "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.",
1769
+ "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. " +
1770
+ FILE_BLOCKED_TOOL_RULE,
1765
1771
  inputSchema: {
1766
1772
  file_id: z.string().uuid().describe("File UUID, the file being changed"),
1767
1773
  name: z.string().optional().describe("New name"),
@@ -1785,7 +1791,7 @@ export function createServer(apiKey, options = {}) {
1785
1791
  server.registerTool("lighthouse_documents_delete_file", {
1786
1792
  title: "Delete File",
1787
1793
  annotations: DESTRUCTIVE,
1788
- 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.",
1794
+ 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. " + FILE_BLOCKED_TOOL_RULE,
1789
1795
  inputSchema: {
1790
1796
  file_id: z.string().uuid().describe("File UUID"),
1791
1797
  },
@@ -1801,7 +1807,7 @@ export function createServer(apiKey, options = {}) {
1801
1807
  server.registerTool("lighthouse_documents_link_file_to_record", {
1802
1808
  title: "Link File to Record",
1803
1809
  annotations: ADDS_IF_MISSING,
1804
- 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.",
1810
+ 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. " + FILE_BLOCKED_TOOL_RULE,
1805
1811
  inputSchema: {
1806
1812
  file_id: z.string().uuid().describe("File UUID"),
1807
1813
  record_type: z.string().describe("company, person, deal, or a custom object permalink"),
@@ -1820,7 +1826,7 @@ export function createServer(apiKey, options = {}) {
1820
1826
  server.registerTool("lighthouse_documents_unlink_file_from_record", {
1821
1827
  title: "Unlink File from Record",
1822
1828
  annotations: DESTRUCTIVE,
1823
- 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.",
1829
+ 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. " + FILE_BLOCKED_TOOL_RULE,
1824
1830
  inputSchema: {
1825
1831
  file_id: z.string().uuid().describe("File UUID"),
1826
1832
  record_type: z.string().describe("company, person, deal, or a custom object permalink"),
@@ -1841,7 +1847,8 @@ export function createServer(apiKey, options = {}) {
1841
1847
  annotations: READ_ONLY,
1842
1848
  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. " +
1843
1849
  "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 }). " +
1844
- "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.",
1850
+ "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. " +
1851
+ `${FILE_BLOCKED_KEYS} ${FILE_BLOCKED_TOOL_RULE}`,
1845
1852
  inputSchema: {
1846
1853
  record_type: z.string().describe("company, person, deal, or a custom object permalink"),
1847
1854
  record_id: z.string().uuid().describe("Record UUID"),
@@ -1891,9 +1898,9 @@ export function createServer(apiKey, options = {}) {
1891
1898
  annotations: DESTRUCTIVE,
1892
1899
  description: "Update a saved CRM view or list view's name, filters, sort, or layout. " +
1893
1900
  "Automatically detects whether it's a CRM view or list view. " +
1894
- "To change sharing settings, use lighthouse_crm_update_view_sharing instead. " +
1895
- "Uses CRM filter format, call lighthouse_crm_list_attributes first if modifying filters. " +
1896
- "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. " +
1901
+ "Sharing settings are changed by lighthouse_crm_update_view_sharing. " +
1902
+ "Uses CRM filter format; filter keys come from lighthouse_crm_list_attributes. " +
1903
+ "A semantic_search condition in the new filters must carry its vector (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. " +
1897
1904
  "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. " +
1898
1905
  SAVED_FILTER_REFUSAL + " " + DOTTED_PATH_NAMES_UNCHECKED,
1899
1906
  inputSchema: {
@@ -1907,7 +1914,7 @@ export function createServer(apiKey, options = {}) {
1907
1914
  .array(z.unknown())
1908
1915
  .nullable()
1909
1916
  .optional()
1910
- .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."),
1917
+ .describe("Kanban views: the values whose column is collapsed. An array of column values, or null to show every column again. lighthouse_crm_get_view returns the current ones."),
1911
1918
  kanban_summary: z
1912
1919
  .record(z.unknown())
1913
1920
  .nullable()
@@ -1960,7 +1967,7 @@ export function createServer(apiKey, options = {}) {
1960
1967
  title: "Get Current User",
1961
1968
  annotations: READ_ONLY,
1962
1969
  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). " +
1963
- "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.",
1970
+ "It is the same object lighthouse_workspace_get_user answers for anyone else.",
1964
1971
  inputSchema: {},
1965
1972
  }, async () => {
1966
1973
  try {
@@ -2088,8 +2095,8 @@ export function createServer(apiKey, options = {}) {
2088
2095
  title: "List Discovery Data Fields",
2089
2096
  annotations: READ_ONLY,
2090
2097
  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. " +
2091
- "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. " +
2092
- "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). " +
2098
+ "These are OUTPUT/data fields, NOT filter keys. A search FILTERS on the prefixed company_* / person_* keys listed in the `filters` parameter of the discovery search tools. " +
2099
+ "To keep the response small it does NOT include option values: when a field has has_options=true, lighthouse_discovery_list_options with that permalink returns its allowed values (as do the dedicated lighthouse_discovery_list_industries / lighthouse_discovery_list_locations / lighthouse_discovery_list_funding_types / lighthouse_discovery_list_tags tools). " +
2093
2100
  "Calling this is optional and NOT required before searching. These are Lighthouse's global database fields, not your CRM fields.",
2094
2101
  inputSchema: {
2095
2102
  record_type: z
@@ -2115,9 +2122,9 @@ export function createServer(apiKey, options = {}) {
2115
2122
  title: "List Discovery Field Options",
2116
2123
  annotations: READ_ONLY,
2117
2124
  description: "Get the allowed values (value, label, color) for a single discovery select / multi_select field. " +
2118
- "Call this when lighthouse_discovery_list_attributes shows has_options=true for a field and you need its valid values. " +
2125
+ "WHEN TO USE: lighthouse_discovery_list_attributes shows has_options=true for a field and its valid values are needed. " +
2119
2126
  "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). " +
2120
- "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.",
2127
+ "The large shared taxonomies also have dedicated tools: lighthouse_discovery_list_industries, lighthouse_discovery_list_locations, lighthouse_discovery_list_funding_types, lighthouse_discovery_list_tags.",
2121
2128
  inputSchema: {
2122
2129
  record_type: z.enum(["company", "person"]).describe("Entity type the field belongs to"),
2123
2130
  field_permalink: z.string().describe("Field permalink (from lighthouse_discovery_list_attributes)"),
@@ -2140,13 +2147,13 @@ export function createServer(apiKey, options = {}) {
2140
2147
  annotations: READ_ONLY,
2141
2148
  description: "Search Lighthouse's global company database to DISCOVER new companies. " +
2142
2149
  "This searches Lighthouse's enriched database (millions of companies), NOT your CRM. " +
2143
- "WORKFLOW: 1) Build filters using ONLY the company_* filter keys listed in the `filters` parameter below. " +
2144
- "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`. " +
2145
- "2) For category filters (company_categories, company_sdg_impact), call lighthouse_discovery_list_industries to get valid values. " +
2146
- "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. " +
2147
- "4) For investor/past-company/education filters, call lighthouse_discovery_search_entities (record_type 'investor', 'company', or 'school') to resolve names to ids. " +
2148
- "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). " +
2149
- "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 recognised, 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." +
2150
+ "Filters take ONLY the company_* filter keys listed in the `filters` parameter below. " +
2151
+ "lighthouse_discovery_list_attributes 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`. " +
2152
+ "Valid values for category filters (company_categories, company_sdg_impact) come from lighthouse_discovery_list_industries. " +
2153
+ "Country/region codes for location filters (company_hq_country, company_hq_region, and any other *_country/*_region key) come from lighthouse_discovery_list_locations. " +
2154
+ "Investor/past-company/education filters take ids, which lighthouse_discovery_search_entities (record_type 'investor', 'company', or 'school') resolves from names. " +
2155
+ "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. Keys are matched exactly, with no variants (no _usd suffix) or abbreviations (hq vs headquarter). " +
2156
+ "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 recognised, each with a did_you_mean suggestion when a close match can be computed). An empty `ignored_filter_fields` confirms every filter was applied." +
2150
2157
  DISCOVERY_CRM_ID_NOTE,
2151
2158
  inputSchema: {
2152
2159
  filters: z
@@ -2171,13 +2178,13 @@ export function createServer(apiKey, options = {}) {
2171
2178
  annotations: READ_ONLY,
2172
2179
  description: "Search Lighthouse's global people database to DISCOVER new people. " +
2173
2180
  "This searches Lighthouse's enriched database, NOT your CRM. " +
2174
- "WORKFLOW: 1) Build filters using ONLY the person_* filter keys listed in the `filters` parameter below. " +
2175
- "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`. " +
2176
- "2) For category filters (person_current_categories, person_past_categories), call lighthouse_discovery_list_industries to get valid values; the same call returns stealth_predicted_areas, the values of the stealth signal's predicted area. " +
2177
- "3) For location filters (person_country, person_region, and any other *_country/*_region key), call lighthouse_discovery_list_locations to get country/region codes. " +
2178
- "4) For past-company/education filters, call lighthouse_discovery_search_entities (record_type 'company' or 'school') to resolve names to ids. " +
2179
- "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. " +
2180
- "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 recognised, 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. " +
2181
+ "Filters take ONLY the person_* filter keys listed in the `filters` parameter below. " +
2182
+ "lighthouse_discovery_list_attributes 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`. " +
2183
+ "Valid values for category filters (person_current_categories, person_past_categories) come from lighthouse_discovery_list_industries; the same call returns stealth_predicted_areas, the values of the stealth signal's predicted area. " +
2184
+ "Country/region codes for location filters (person_country, person_region, and any other *_country/*_region key) come from lighthouse_discovery_list_locations. " +
2185
+ "Past-company/education filters take ids, which lighthouse_discovery_search_entities (record_type 'company' or 'school') resolves from names. " +
2186
+ "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. Keys are matched exactly, with no variants or abbreviations. " +
2187
+ "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 recognised, each with a did_you_mean suggestion when a close match can be computed). An empty `ignored_filter_fields` confirms every filter was applied. " +
2181
2188
  "People results are returned by founder relevance; custom sorting is NOT supported for people, so there is no sort parameter." +
2182
2189
  DISCOVERY_CRM_ID_NOTE,
2183
2190
  inputSchema: {
@@ -2201,11 +2208,11 @@ export function createServer(apiKey, options = {}) {
2201
2208
  title: "Semantic Search Companies",
2202
2209
  annotations: READ_ONLY,
2203
2210
  description: "Find companies by MEANING, not exact keywords. Describe the kind of company you are looking for in plain language (what it does, who it serves, its sector, its business model) and Lighthouse returns the most semantically similar companies from its global database (NOT your CRM), ranked by relevance. " +
2204
- "USE THIS when the request is conceptual or hard to express as exact filters, e.g. 'companies like Stripe but for healthcare', 'startups building developer tools for AI agents', 'sustainable packaging for e-commerce'. " +
2205
- "You CAN combine it with structured filters: pass `filters` using the same company_* keys as lighthouse_discovery_search_companies and they apply together (semantic match AND your filters), e.g. semantically-similar companies that are ALSO headquartered in Germany. " +
2206
- "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. " +
2207
- "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. " +
2208
- "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 recognised, 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. " +
2211
+ "WHEN TO USE: the request is conceptual or hard to express as exact filters, e.g. 'companies like Stripe but for healthcare', 'startups building developer tools for AI agents', 'sustainable packaging for e-commerce'. " +
2212
+ "It combines with structured filters: `filters` takes the same company_* keys as lighthouse_discovery_search_companies and they apply together (semantic match AND your filters), e.g. semantically-similar companies that are ALSO headquartered in Germany. " +
2213
+ "lighthouse_discovery_search_companies (no `query`) covers exact attribute filters (a specific country, funding stage, headcount range) with no conceptual component. " +
2214
+ "This cannot satisfy an exact field the database does not expose, a guarantee of completeness, or ordering by a specific metric. " +
2215
+ "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 recognised, each with a did_you_mean suggestion when a close match can be computed). An empty `ignored_filter_fields` confirms every filter was applied. " +
2209
2216
  "`query` is required; everything else is optional." +
2210
2217
  DISCOVERY_CRM_ID_NOTE,
2211
2218
  inputSchema: {
@@ -2236,11 +2243,11 @@ export function createServer(apiKey, options = {}) {
2236
2243
  title: "Semantic Search People",
2237
2244
  annotations: READ_ONLY,
2238
2245
  description: "Find people by MEANING, not exact keywords. Describe the ideal person in plain language (their role, skills, the industry they work in, their background) and Lighthouse returns the most semantically similar people from its global database (NOT your CRM), ranked by relevance. " +
2239
- "USE THIS when the request is conceptual or hard to express as exact filters, e.g. 'technical co-founders who worked on payments infrastructure', 'ex-FAANG ML engineers now in climate tech'. " +
2240
- "You CAN combine it with structured filters: pass `filters` using the same person_* keys as lighthouse_discovery_search_people and they apply together (semantic match AND your filters), e.g. semantically-similar people who are ALSO based in the UK. " +
2241
- "PREFER lighthouse_discovery_search_people (no `query`) when the user only needs exact attribute filters with no conceptual component. " +
2242
- "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. " +
2243
- "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 recognised, 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. " +
2246
+ "WHEN TO USE: the request is conceptual or hard to express as exact filters, e.g. 'technical co-founders who worked on payments infrastructure', 'ex-FAANG ML engineers now in climate tech'. " +
2247
+ "It combines with structured filters: `filters` takes the same person_* keys as lighthouse_discovery_search_people and they apply together (semantic match AND your filters), e.g. semantically-similar people who are ALSO based in the UK. " +
2248
+ "lighthouse_discovery_search_people (no `query`) covers exact attribute filters with no conceptual component. " +
2249
+ "This cannot satisfy an exact field the database does not expose, a guarantee of completeness, or ordering by a specific metric. " +
2250
+ "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 recognised, each with a did_you_mean suggestion when a close match can be computed). An empty `ignored_filter_fields` confirms every filter was applied. " +
2244
2251
  "`query` is required; everything else is optional." +
2245
2252
  DISCOVERY_CRM_ID_NOTE,
2246
2253
  inputSchema: {
@@ -2274,7 +2281,7 @@ export function createServer(apiKey, options = {}) {
2274
2281
  "Returns enriched company profiles for matching domains. Max 1000 domains per request. " +
2275
2282
  "Each returned profile is condensed (the long growth and workforce history and the activity-signal " +
2276
2283
  "feed are omitted, and the associated-people list is capped) to keep the response compact; for a " +
2277
- "single company's full profile call lighthouse_discovery_get_company. " +
2284
+ "single company's full profile, see lighthouse_discovery_get_company. " +
2278
2285
  "Consumes API entity credits for each new company returned." +
2279
2286
  DISCOVERY_CRM_ID_NOTE,
2280
2287
  inputSchema: {
@@ -2298,7 +2305,7 @@ export function createServer(apiKey, options = {}) {
2298
2305
  "Returns enriched company profiles for matching LinkedIn company pages. Max 1000 URLs per request. " +
2299
2306
  "Each returned profile is condensed (the long growth and workforce history and the activity-signal " +
2300
2307
  "feed are omitted, and the associated-people list is capped) to keep the response compact; for a " +
2301
- "single company's full profile call lighthouse_discovery_get_company. " +
2308
+ "single company's full profile, see lighthouse_discovery_get_company. " +
2302
2309
  "Consumes API entity credits for each new company returned." +
2303
2310
  DISCOVERY_CRM_ID_NOTE,
2304
2311
  inputSchema: {
@@ -2321,7 +2328,7 @@ export function createServer(apiKey, options = {}) {
2321
2328
  description: "Look up people in Lighthouse's global database by LinkedIn profile URL(s). " +
2322
2329
  "Returns enriched person profiles for matching LinkedIn pages. Max 1000 URLs per request. " +
2323
2330
  "Each returned profile is condensed (the activity-signal feed is omitted and the associated-companies " +
2324
- "and education lists are capped) to keep the response compact; for a single person's full profile call " +
2331
+ "and education lists are capped) to keep the response compact; for a single person's full profile, see " +
2325
2332
  "lighthouse_discovery_get_person. " +
2326
2333
  "Consumes API entity credits for each new person returned." +
2327
2334
  DISCOVERY_CRM_ID_NOTE,
@@ -2345,7 +2352,7 @@ export function createServer(apiKey, options = {}) {
2345
2352
  description: "List all saved discovery searches in the workspace. " +
2346
2353
  "Returns saved searches you own and workspace-shared searches. " +
2347
2354
  "Each search includes its filters and type (company or person). " +
2348
- "Use the search ID with lighthouse_discovery_list_search_results to run a saved search.",
2355
+ "lighthouse_discovery_list_search_results runs a saved search by this search ID.",
2349
2356
  inputSchema: {},
2350
2357
  }, async () => {
2351
2358
  try {
@@ -2384,7 +2391,7 @@ export function createServer(apiKey, options = {}) {
2384
2391
  title: "List Location Options",
2385
2392
  annotations: READ_ONLY,
2386
2393
  description: "Get available location options (countries and regions) for discovery filters. " +
2387
- "ALWAYS call this to get valid country/region values before filtering by location fields " +
2394
+ "WHEN TO USE: getting valid country/region values for filtering by location fields " +
2388
2395
  "(company_hq_country, company_hq_region, person_country, person_region, company_investor_country, etc.). " +
2389
2396
  "Returns {countries: [{name, value, flag}], regions: [{name, value, flag}], registration_countries: [{name, value, flag}]}. " +
2390
2397
  "Use 'value' (alpha-2 codes for countries e.g. 'DE' for Germany, IDs for regions) " +
@@ -2405,9 +2412,9 @@ export function createServer(apiKey, options = {}) {
2405
2412
  title: "List Industry & Category Options",
2406
2413
  annotations: READ_ONLY,
2407
2414
  description: "Get available industry and category options for discovery filters. " +
2408
- "ALWAYS call this to get valid category values before filtering by company_categories, company_sdg_impact, " +
2415
+ "WHEN TO USE: getting valid category values for filtering by company_categories, company_sdg_impact, " +
2409
2416
  "person_current_categories, person_past_categories, person_current_sdg_impact, or person_past_sdg_impact, " +
2410
- "and before filtering stealth founders by predicted area (stealth_predicted_areas of person_signals, person_stealth_predicted_area). " +
2417
+ "and for filtering stealth founders by predicted area (stealth_predicted_areas of person_signals, person_stealth_predicted_area). " +
2411
2418
  "Returns {categories: [{name, value}], sdg_categories: [{name, value}], stealth_predicted_areas: [{name, value}]}. " +
2412
2419
  "Categories include sectors like SaaS, B2B, FinTech, HealthTech, etc. " +
2413
2420
  "Use 'value' (permalink string) in filter conditions with contains_any/not_contains_any/contains_all operators. " +
@@ -2427,7 +2434,7 @@ export function createServer(apiKey, options = {}) {
2427
2434
  title: "List Tag Options",
2428
2435
  annotations: READ_ONLY,
2429
2436
  description: "Get the tag options for discovery filters. " +
2430
- "ALWAYS call this to get valid tag values before filtering by company_tags or person_tags. " +
2437
+ "WHEN TO USE: getting valid tag values for filtering by company_tags or person_tags. " +
2431
2438
  "Returns {company_tags: [{value, name}], person_tags: [{value, name}]}. " +
2432
2439
  "Use 'value' in filter conditions with contains_any/not_contains_any/contains_all operators.",
2433
2440
  inputSchema: {},
@@ -2463,9 +2470,9 @@ export function createServer(apiKey, options = {}) {
2463
2470
  title: "Search Investors by Name",
2464
2471
  annotations: READ_ONLY,
2465
2472
  description: "Search for investors by name to get their UUIDs for use in discovery filters. " +
2466
- "Use the returned 'id' values in company_investor filter conditions (investor_multi_select type). " +
2467
- "Returns [{id, name, avatar_url}]. Pass the id (or [{id, label}] objects) as the filter value. " +
2468
- "DEPRECATED: use lighthouse_discovery_search_entities with record_type 'investor' instead (better, typo-tolerant ranking). Kept for backward compatibility.",
2473
+ "The returned 'id' values are what company_investor filter conditions take (investor_multi_select type). " +
2474
+ "Returns [{id, name, avatar_url}]. The filter value is the id (or [{id, label}] objects). " +
2475
+ "DEPRECATED: lighthouse_discovery_search_entities with record_type 'investor' replaces it (better, typo-tolerant ranking). Kept for backward compatibility.",
2469
2476
  inputSchema: {
2470
2477
  query: z.string().describe("Investor name to search for (e.g. 'Sequoia', 'a16z')"),
2471
2478
  },
@@ -2482,11 +2489,11 @@ export function createServer(apiKey, options = {}) {
2482
2489
  title: "Search Organizations by Name",
2483
2490
  annotations: READ_ONLY,
2484
2491
  description: "Search for organizations (companies) by name to get their UUIDs for use in discovery filters. " +
2485
- "Use the returned 'id' values in work_experience_multi_select filter conditions " +
2492
+ "The returned 'id' values are what work_experience_multi_select filter conditions take " +
2486
2493
  "(company_team_past_companies, person_past_companies). " +
2487
2494
  "Returns a THIN identity row [{id, name, avatar_url, domain, short_description, headcount, location, total_funding_amount_usd}]. This is NOT a full company profile. " +
2488
- "For the complete profile, pass the id to lighthouse_discovery_get_company (or use lighthouse_discovery_get_entity_by_name); do not report a company's details as missing based on this row. " +
2489
- "DEPRECATED: use lighthouse_discovery_search_entities with record_type 'company' instead (better, typo-tolerant ranking). Kept for backward compatibility.",
2495
+ "The complete profile comes from lighthouse_discovery_get_company with this id (or lighthouse_discovery_get_entity_by_name). " +
2496
+ "DEPRECATED: lighthouse_discovery_search_entities with record_type 'company' replaces it (better, typo-tolerant ranking). Kept for backward compatibility.",
2490
2497
  inputSchema: {
2491
2498
  query: z.string().describe("Organization name to search for (e.g. 'Google', 'McKinsey')"),
2492
2499
  },
@@ -2503,10 +2510,10 @@ export function createServer(apiKey, options = {}) {
2503
2510
  title: "Search Schools by Name",
2504
2511
  annotations: READ_ONLY,
2505
2512
  description: "Search for educational institutions by name to get their UUIDs for use in discovery filters. " +
2506
- "Use the returned 'id' values in education_multi_select filter conditions " +
2513
+ "The returned 'id' values are what education_multi_select filter conditions take " +
2507
2514
  "(company_team_education, person_education). " +
2508
2515
  "Returns [{id, name, avatar_url}]. " +
2509
- "DEPRECATED: use lighthouse_discovery_search_entities with record_type 'school' instead (better, typo-tolerant ranking). Kept for backward compatibility.",
2516
+ "DEPRECATED: lighthouse_discovery_search_entities with record_type 'school' replaces it (better, typo-tolerant ranking). Kept for backward compatibility.",
2510
2517
  inputSchema: {
2511
2518
  query: z.string().describe("Institution name to search for (e.g. 'MIT', 'Stanford', 'Harvard')"),
2512
2519
  },
@@ -2528,10 +2535,10 @@ export function createServer(apiKey, options = {}) {
2528
2535
  "searching 'Apple' returns the largest Apple first). " +
2529
2536
  "Use this to: (a) get an id to drop into a discovery filter (past companies, education, investor " +
2530
2537
  "filters), or (b) locate an entity before reading its full profile. " +
2531
- "It searches ALL entities, including ones that do not appear in discovery result lists, so you will " +
2532
- "always find a match if one exists. If you only need an id for a filter, stop after this call. " +
2533
- "To read full details, pass the returned id to lighthouse_discovery_get_company (companies) or " +
2534
- "lighthouse_discovery_get_person (people). " +
2538
+ "It searches ALL entities, including ones that do not appear in discovery result lists, so a match " +
2539
+ "is found if one exists. An id for a filter needs nothing further. " +
2540
+ "Full details come from lighthouse_discovery_get_company (companies) or " +
2541
+ "lighthouse_discovery_get_person (people) with the returned id. " +
2535
2542
  "is_discoverable means the entity also appears in discovery result lists; when false you can still " +
2536
2543
  "read its full profile by id, it just will not show up in list/search-results endpoints.",
2537
2544
  inputSchema: {
@@ -2565,7 +2572,7 @@ export function createServer(apiKey, options = {}) {
2565
2572
  "history and the activity-signal feed are omitted, and the associated-people list is capped. " +
2566
2573
  "Always returns data for an entity that exists, including companies not shown in discovery result " +
2567
2574
  "lists, so there is no dead-end. " +
2568
- "Obtain the id from lighthouse_discovery_search_entities (record_type 'company') first. " +
2575
+ "The id comes from lighthouse_discovery_search_entities (record_type 'company'). " +
2569
2576
  "This accepts only a company id (not a person, investor, or school id), and does not search by name. " +
2570
2577
  "Each investor on a funding round carries is_lead: true when that investor led the round, false when " +
2571
2578
  "it did not, never null. is_lead_inv is the same fact under the older name and can be null where " +
@@ -2592,7 +2599,7 @@ export function createServer(apiKey, options = {}) {
2592
2599
  "and the associated-companies and education lists are capped. " +
2593
2600
  "Always returns data for an entity that exists, including people not shown in discovery result lists, " +
2594
2601
  "so there is no dead-end. " +
2595
- "Obtain the id from lighthouse_discovery_search_entities (record_type 'person') first. " +
2602
+ "The id comes from lighthouse_discovery_search_entities (record_type 'person'). " +
2596
2603
  "This accepts only a person id, and does not search by name. " +
2597
2604
  "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." +
2598
2605
  DISCOVERY_CRM_ID_NOTE,
@@ -2652,7 +2659,7 @@ export function createServer(apiKey, options = {}) {
2652
2659
  description: "The detected events for one person in the global database, by person id: the signal feed lighthouse_discovery_get_person omits. " +
2653
2660
  "signals is a list of { type, created_at, new_company, domain, org_id, predicted_areas }: a new company founded or registered, a stealth period, a departure and similar; org_id is a company id you can pass to lighthouse_discovery_get_company, domain its website. Sort by created_at yourself. " +
2654
2661
  "predicted_areas is what Lighthouse predicts the founder is building, on a stealth or out_of_stealth signal: [{ permalink, label, origin }], permalink being a value of lighthouse_discovery_list_industries → stealth_predicted_areas and origin what the prediction was read from; it is [] on every other signal and when nothing was predicted. " +
2655
- "WHEN TO USE: \"is this founder starting something new\", \"did they leave their company\", \"what is this stealth founder building\", \"any recent moves by X\". For the person's work history use lighthouse_discovery_get_person. " +
2662
+ "WHEN TO USE: \"is this founder starting something new\", \"did they leave their company\", \"what is this stealth founder building\", \"any recent moves by X\". lighthouse_discovery_get_person has the person's work history. " +
2656
2663
  "A null or empty list means no events are recorded. Same id as lighthouse_discovery_get_person.",
2657
2664
  inputSchema: {
2658
2665
  id: z.string().describe("The person id (UUID) returned by lighthouse_discovery_search_entities"),
@@ -2673,10 +2680,10 @@ export function createServer(apiKey, options = {}) {
2673
2680
  "Resolves the single best-ranked match and returns its full profile in one call (equivalent to " +
2674
2681
  "lighthouse_discovery_search_entities followed by lighthouse_discovery_get_company / get_person, combined). " +
2675
2682
  "For investors and schools it returns the lightweight identity row (there is no deep profile for those). " +
2676
- "It picks the single top match, so when the name is ambiguous and you want to choose among candidates, " +
2677
- "use lighthouse_discovery_search_entities instead and pass the chosen id to get_company / get_person. " +
2678
- "Do NOT build a profile from a search/lookup result row (those are thin identity rows): use this tool, " +
2679
- "or get_company / get_person with the id." +
2683
+ "It picks the single top match; lighthouse_discovery_search_entities lists the candidates for an ambiguous name, " +
2684
+ "and get_company / get_person read a chosen id. " +
2685
+ "A search/lookup result row is a thin identity row, not a profile: this tool, " +
2686
+ "or get_company / get_person with the id, returns the profile." +
2680
2687
  DISCOVERY_CRM_ID_NOTE,
2681
2688
  inputSchema: {
2682
2689
  record_type: z
@@ -2697,22 +2704,22 @@ export function createServer(apiKey, options = {}) {
2697
2704
  "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. " +
2698
2705
  "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), " +
2699
2706
  "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. " +
2700
- "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. " +
2707
+ "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. SUM/AVG over a multi-value (multi_select/multi_user/multi_record) dimension or segment over-counts the value; COUNT does not. " +
2701
2708
  "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. " +
2702
2709
  "filter?:{operator:'AND'|'OR', conditions:[...], groups:[...]}, " +
2703
2710
  "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'} " +
2704
- "OR {type:'absolute', start_date:'YYYY-MM-DD', end_date:'YYYY-MM-DD'}. NOTE: date_range scopes the date FIELD you group by (the x-axis) when the dimension is a date/datetime/datetimez field, so it only makes sense for a date-dimension report; for a non-date dimension it falls back to the record's creation date, so prefer a filter condition instead. " +
2711
+ "OR {type:'absolute', start_date:'YYYY-MM-DD', end_date:'YYYY-MM-DD'}. NOTE: date_range scopes the date FIELD you group by (the x-axis) when the dimension is a date/datetime/datetimez field, so it only makes sense for a date-dimension report; for a non-date dimension it falls back to the record's creation date, and a filter condition scopes the report instead. " +
2705
2712
  "included_stages?:['value1','value2'], show_axis_labels?:boolean, show_zeros?:boolean, sort_by_stage_order?:boolean}. " +
2706
2713
  "INTERACTIONS (person/company data_source only): you can group or segment by an interaction date " +
2707
2714
  "(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) " +
2708
2715
  "or by first_interaction_type/last_interaction_type, all with is_custom_field:false; and you can measure lifetime interaction totals with " +
2709
2716
  "measure.field:'__ix_count_all__'|'__ix_count_email__'|'__ix_count_linkedin__'|'__ix_count_meeting__' (is_custom_field:false; the aggregation is ignored). " +
2710
- "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), " +
2717
+ "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; without it 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), " +
2711
2718
  "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)}. " +
2712
- "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), " +
2719
+ "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, required; sending record_type instead fills both), " +
2713
2720
  "field_permalink:'<status or select field_key>' (one value at a time, standard or custom, such as a deal's stage; a multi-select field, or a field Lighthouse computes such as relationship_status, is refused), time_grouping?:'day'|'week'|'month'|'quarter'|'year', included_stages?:['stage1','stage2'], date_range?:{...}}. " +
2714
2721
  "included_stages values must be the option VALUES from lighthouse_crm_list_attributes (not display labels). " +
2715
- "ALWAYS call lighthouse_crm_list_attributes first to discover valid field keys for dimension, measure, and stage_field.";
2722
+ "Valid field keys for dimension, measure, and stage_field come from lighthouse_crm_list_attributes.";
2716
2723
  server.registerTool("lighthouse_reports_list_dashboards", {
2717
2724
  title: "List Dashboards",
2718
2725
  annotations: READ_ONLY,
@@ -2838,8 +2845,7 @@ export function createServer(apiKey, options = {}) {
2838
2845
  title: "Create Report Widget",
2839
2846
  annotations: CREATES,
2840
2847
  description: "Create a report widget (chart/table/KPI) on a dashboard. " +
2841
- "WORKFLOW: 1) Call lighthouse_crm_list_attributes to discover field keys for the entity type, " +
2842
- "2) Build a config object using those keys, 3) Create the widget. " +
2848
+ "The config's field keys for the entity type come from lighthouse_crm_list_attributes. " +
2843
2849
  "Widget types: bar, line, pie, table, kpi, funnel, area. " +
2844
2850
  "Report types: standard (aggregate by field), funnel (pipeline conversion), stage_changes (historical transitions). " +
2845
2851
  "The config's filter (and the older flat filters) uses the CRM filter format of lighthouse_crm_search_records, on the widget's record type. " +
@@ -2910,7 +2916,7 @@ export function createServer(apiKey, options = {}) {
2910
2916
  "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). " +
2911
2917
  "For funnel reports: array of {stage, count} rows. " +
2912
2918
  "For historical reports: array of {period, from_stage, to_stage, transition_count} rows. " +
2913
- "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.",
2919
+ "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, so the flag, not the label, says a row has 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.",
2914
2920
  inputSchema: {
2915
2921
  dashboard_id: z.string().uuid().describe("Dashboard ID"),
2916
2922
  widget_id: z.string().uuid().describe("Widget ID"),
@@ -2928,7 +2934,7 @@ export function createServer(apiKey, options = {}) {
2928
2934
  title: "Share Dashboard with Teams",
2929
2935
  annotations: ADDS_IF_MISSING,
2930
2936
  description: "Grant team-level access to a dashboard. Only the dashboard owner can share. " +
2931
- "Use lighthouse_workspace_list_teams to find team IDs first. " +
2937
+ "Team IDs come from lighthouse_workspace_list_teams. " +
2932
2938
  "Requires reports.edit permission. " +
2933
2939
  SHARE_ALONG_RULE,
2934
2940
  inputSchema: {
@@ -2972,7 +2978,7 @@ export function createServer(apiKey, options = {}) {
2972
2978
  title: "Share View with Teams",
2973
2979
  annotations: ADDS_IF_MISSING,
2974
2980
  description: "Grant team-level access to a CRM view or list view. Only the view owner can share. " +
2975
- "Use lighthouse_workspace_list_teams to find team IDs first. " +
2981
+ "Team IDs come from lighthouse_workspace_list_teams. " +
2976
2982
  "Requires views.share permission. " +
2977
2983
  "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. " +
2978
2984
  SHARE_ALONG_RULE,
@@ -3039,6 +3045,186 @@ export function createServer(apiKey, options = {}) {
3039
3045
  return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
3040
3046
  }
3041
3047
  });
3048
+ const SEQUENCE_PEOPLE_PER_CALL_MAX = 100;
3049
+ const SEQUENCE_FINISHED_ELSEWHERE_DAYS = 30;
3050
+ const SEQUENCE_END_REASONS = "finished (went through every step), replied (their reply ended the run, or they replied after its last step), colleague_replied (someone else from their company replied), wrote_first (they wrote in before this sequence contacted them; not a reply), accepted_invitation, already_connected, not_accepted (did not accept the LinkedIn invitation), stopped_manually, unsubscribed, bounced, failed or stopped";
3051
+ const SEQUENCE_PERSON_STATUS = "status is the person's own place in the sequence, never the sequence's: in_review (waiting in the sequence's Review for a member to add them), ready (added from Review, waiting to start), active, paused and account_disconnected (waiting for its sending account, or for a check of any reply sent while it was disconnected) are still going; completed, stopped, failed, bounced and unsubscribed have ended. " +
3052
+ "end_reason only says why a run ENDED: a reply the sequence's rules let it carry on through shows only in outcome, so for everyone who replied read outcome too.";
3053
+ const SEQUENCE_STOPPABLE_RUNS = "ready, active, paused, or waiting for an account that can send or for a check of their replies";
3054
+ const SEQUENCE_RESUME_CHECK_RULE = "once any check of the replies they sent while an account was disconnected is done; a check that could not be made is ended, so they go on without it";
3055
+ const SEQUENCE_MEMBER = "{ id, first_name, last_name, full_name, avatar_url, email }";
3056
+ const SEQUENCE_PERSON_KEYS = "id (this person's run in the sequence), crm_person { id, first_name, last_name, avatar_url } (the CRM person: its id is what lighthouse_sequences_add_people, lighthouse_sequences_update_people and lighthouse_sequences_remove_people take, and what lighthouse_crm_get_record reads), " +
3057
+ "email (the address the run sends to), company { id, name, avatar_url, is_deleted } (the company its messages name, null when none), status, step_status (what the current step is doing, null when nothing), current_step { number (from 1), type } (null before the first step), " +
3058
+ "outcome (how their reply reads: positive, negative, neutral or automated; null with no reply), outcome_source (ai or manual), stop_reason, stopped_by { id, first_name, last_name } (the person from their company whose reply stopped this run; id is that colleague's own run in this sequence, not a CRM person id), " +
3059
+ "end_reason (why the run ended: " + SEQUENCE_END_REASONS + "; null while it is still going), ended_by (the member who ended it by hand, " + SEQUENCE_MEMBER + "; null when the sequence or the person ended it), last_error_code, " +
3060
+ "added_via (manual, csv, list, find_leads, crm_action, api, workflow or assistant), started_at, ended_at, created_at";
3061
+ const SEQUENCE_NOT_FOUND_RULE = "A sequence the API key's user cannot open answers 'Sequence not found', the same as one that does not exist.";
3062
+ server.registerTool("lighthouse_sequences_list", {
3063
+ title: "List Sequences",
3064
+ annotations: READ_ONLY,
3065
+ description: "List the outbound sequences the API key's user can open (their own, and those shared with the workspace or with one of their teams), newest first. A sequence sends each person in it a series of steps (emails, LinkedIn connection requests and LinkedIn messages) from connected accounts. " +
3066
+ "Each row: id, name, status (draft, active or paused), sharing (private or workspace), shared_with_teams [{ team_id, team_name, granted_at }], is_owner (true when the API key's user owns it), has_unpublished_changes (the app holds an edit not published yet; what is read here is the published sequence, what runs), owner (the member who created it, " + SEQUENCE_MEMBER + "), step_count, stats, created_at and updated_at. " +
3067
+ "stats counts its people: people (everyone, ended runs included), in_review (waiting in the sequence's Review), not_started (added from Review, waiting to start), active, ended, errors, contacted (sent at least one message or connection request, or wrote back), responded (wrote back; an out-of-office or a delivery notice never counts) and positive_responses. The response rate is responded / contacted. " +
3068
+ "WHEN TO USE: \"which sequences do we have\", \"how is our outreach doing\", or to find a sequence someone names: pass query with part of its name and read the id off the matching row instead of paging through all of them. meta.total counts the sequences that matched. " +
3069
+ "lighthouse_sequences_get returns one sequence's steps, settings and sending accounts, and lighthouse_sequences_list_people who is in it. Whether a given person, company or deal is in a sequence is lighthouse_crm_list_record_sequences (a person's record also carries a sequences field), never a walk through every sequence. " +
3070
+ "Creating a sequence, editing its steps or settings, sharing it and turning it on or off are done in the app: no tool does them.",
3071
+ inputSchema: {
3072
+ query: z.string().optional().describe("Part of the sequence's name, in any case"),
3073
+ search: z.string().optional().describe("Same as query. Either name works; query wins when both are sent."),
3074
+ status: z.enum(["draft", "active", "paused"]).optional().describe("Only the sequences with this status. All of them when omitted"),
3075
+ limit: z.number().min(1).max(100).default(25).describe("Sequences per page (max 100)"),
3076
+ offset: z.number().min(0).default(0).describe("Sequences to skip; meta.total and meta.has_more say whether more remain"),
3077
+ },
3078
+ }, async (params) => {
3079
+ try {
3080
+ const res = await api.get("/sequences", params);
3081
+ return text(res);
3082
+ }
3083
+ catch (e) {
3084
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
3085
+ }
3086
+ });
3087
+ server.registerTool("lighthouse_sequences_get", {
3088
+ title: "Get Sequence",
3089
+ annotations: READ_ONLY,
3090
+ description: "One sequence in full, by id: every key of a lighthouse_sequences_list row plus settings, people_not_started (people added from Review and still waiting to start), steps and senders. The steps and settings are the PUBLISHED sequence, what runs; has_unpublished_changes says the app holds an edit not published yet. " +
3091
+ "steps, in order, each { id, number (from 1), type (email, linkedin_connection_request or linkedin_message), channel (email or linkedin), delay { amount, unit (hours, days or weeks) } (the wait before the step, counted from when a person reaches it; null when it runs right away), message_mode (shared: one message for everyone, per_person: a draft written for each person, or ai: each person's message written by AI when they are added), subject (email steps only), message (the shared text, or a connection request's note; null when none, and on a per_person or ai step), include_signature (null on a connection request), sender_id (the sending account this step always uses, null when the sequence chooses), if_accepted and if_not_accepted (a connection request's rules, null on other steps), people_at_step, errors_at_step, drafts_written }. " +
3092
+ "senders, the accounts it sends from, each { id (what a step's sender_id points at), channel, account { id, name, email, is_owner }, owner (the member, " + SEQUENCE_MEMBER + "), is_connected, can_send (false: the people it would send to wait), daily_limit (this sequence's own, null when the account's applies), account_daily_limit, account_sent_today, sequence_sent_today }. " +
3093
+ "settings: schedule { days (ISO weekdays, 1 = Monday), window_start, window_end (HH:MM), timezone }, enrollment { daily_starts, prioritise_new_people }, senders { email, linkedin: { mode (rotate or fixed), fixed_sender_id } }, email_min_gap_minutes, linkedin_min_gap_minutes, stop_on_bounce, include_unsubscribe_link, unsubscribe_link_text and step_defaults (the rules every step follows); a value that is null was never changed, so the product default applies. " +
3094
+ "WHEN TO USE: what a sequence does (its steps in order, the wait before each, its messages, when it sends and from which accounts), or why its people are waiting (a sender that cannot send). lighthouse_sequences_list_people returns who is in it. " + SEQUENCE_NOT_FOUND_RULE,
3095
+ inputSchema: {
3096
+ id: z.string().uuid().describe("Sequence ID, from lighthouse_sequences_list or the sequence of a lighthouse_crm_list_record_sequences row"),
3097
+ },
3098
+ }, async (params) => {
3099
+ try {
3100
+ const res = await api.get(`/sequences/${params.id}`);
3101
+ return text(res);
3102
+ }
3103
+ catch (e) {
3104
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
3105
+ }
3106
+ });
3107
+ server.registerTool("lighthouse_sequences_list_people", {
3108
+ title: "List Sequence People",
3109
+ annotations: READ_ONLY,
3110
+ description: "The people in ONE sequence, newest added first, one page at a time. Each row: " + SEQUENCE_PERSON_KEYS + ", linkedin (the LinkedIn handle the run uses) and sender (the member currently assigned to send to this person, null until one is picked and once that member has left the workspace). " +
3111
+ SEQUENCE_PERSON_STATUS + " " +
3112
+ "meta.counts { running, ended, by_reason { <end_reason>: n } } follows query but never state or end_reason, so one call says how many there are of each. " +
3113
+ "WHEN TO USE: \"who is in this sequence\", \"who is still going\" (state running), \"who replied\" (end_reason replied, then outcome on the others), \"who bounced\", or finding one person in it by name or address (query). " +
3114
+ "Whether one person, company or deal is in any sequence is lighthouse_crm_list_record_sequences. Needs a role that may view both sequences and people. " + SEQUENCE_NOT_FOUND_RULE,
3115
+ inputSchema: {
3116
+ sequence_id: z.string().uuid().describe("Sequence ID, from lighthouse_sequences_list"),
3117
+ query: z.string().optional().describe("Part of the person's name or of the address the sequence sends to, in any case"),
3118
+ search: z.string().optional().describe("Same as query. Either name works; query wins when both are sent."),
3119
+ state: z.enum(["running", "ended"]).optional().describe("running: their run is still going (Review included); ended: it is over. Both when omitted"),
3120
+ end_reason: z.string().optional().describe("Only the people whose run ended this way (implies ended, so state must be ended or left out): " + SEQUENCE_END_REASONS),
3121
+ limit: z.number().min(1).max(100).default(25).describe("People per page (max 100)"),
3122
+ offset: z.number().min(0).default(0).describe("People to skip; meta.total and meta.has_more say whether more remain"),
3123
+ },
3124
+ }, async (params) => {
3125
+ try {
3126
+ const { sequence_id, ...rest } = params;
3127
+ const res = await api.get(`/sequences/${sequence_id}/people`, rest);
3128
+ return text(res);
3129
+ }
3130
+ catch (e) {
3131
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
3132
+ }
3133
+ });
3134
+ server.registerTool("lighthouse_sequences_add_people", {
3135
+ title: "Add People to Sequence",
3136
+ annotations: ADDS_IF_MISSING,
3137
+ description: `Add CRM PEOPLE to a sequence, up to ${SEQUENCE_PEOPLE_PER_CALL_MAX} per call: crm_person_ids are person record ids, never companies, deals or lists. ` +
3138
+ "Everyone added lands in the sequence's Review, exactly like an add made in the app, and NOTHING IS SENT until a member opens the sequence in the app and adds them from Review; no tool starts anyone right away, so the people are waiting in Review, not contacted. " +
3139
+ "When the sequence has a step written by AI (message_mode ai), each person added has their message on it written straight away, while they wait in Review. That uses the workspace's AI credits, shown as Sequence messages on the Credits page, unless the step writes on one of the workspace's own models; a step with no model chosen writes on the workspace's default model when it has one, which uses none. " +
3140
+ "Answers { added, counts, results, other_sequences, other_sequences_hidden }: results has one { crm_person_id, result } per id sent, in order (a repeated id is answered once), and counts carries every result word, 0 when none. " +
3141
+ "result is added (now in the sequence's Review), already_in_sequence (a person is in a sequence at most once, ever: an ended run is not restarted), in_other_sequence (still going or in Review in another sequence; send include_in_other_sequences true to add them anyway), duplicate_email or duplicate_linkedin (someone else in this sequence is reached at the same address or LinkedIn profile), unsubscribed (opted out, or their address bounced and the sequence has no LinkedIn step), finished_other_sequence (only with skip_finished_elsewhere) or crm_person_not_found (no person with that id in the workspace: a company or deal id, or a deleted person). " +
3142
+ "other_sequences names the sequences ({ id, name }) holding the people skipped as in_other_sequence that the API key's user can open; other_sequences_hidden counts the others. " +
3143
+ "WHEN TO USE: the user asks to put people into a sequence (\"add these founders to our outreach\"). lighthouse_sequences_list finds the sequence and lighthouse_crm_search_records the people. Adding the same people again changes nothing (already_in_sequence). Needs a role that may edit sequences and view people. " + SEQUENCE_NOT_FOUND_RULE,
3144
+ inputSchema: {
3145
+ sequence_id: z.string().uuid().describe("Sequence ID, from lighthouse_sequences_list"),
3146
+ crm_person_ids: z.array(z.string().uuid()).min(1).max(SEQUENCE_PEOPLE_PER_CALL_MAX).describe(`CRM person record IDs to add (max ${SEQUENCE_PEOPLE_PER_CALL_MAX}). People only: a company or deal id comes back as crm_person_not_found`),
3147
+ include_in_other_sequences: z.boolean().optional().describe("true also adds the people still going or waiting in Review in another sequence. Default false: they are skipped as in_other_sequence"),
3148
+ skip_finished_elsewhere: z.boolean().optional().describe(`true leaves out the people who were contacted in another sequence and whose run there ended in the last ${SEQUENCE_FINISHED_ELSEWHERE_DAYS} days (result finished_other_sequence); with include_in_other_sequences also true, someone still live in another sequence is added all the same. Default false`),
3149
+ },
3150
+ }, async (params) => {
3151
+ try {
3152
+ const { sequence_id, ...body } = params;
3153
+ const res = await api.post(`/sequences/${sequence_id}/people`, body);
3154
+ return text(res);
3155
+ }
3156
+ catch (e) {
3157
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
3158
+ }
3159
+ });
3160
+ server.registerTool("lighthouse_sequences_update_people", {
3161
+ title: "Update People in Sequence",
3162
+ annotations: DESTRUCTIVE,
3163
+ description: `Stop, pause or resume CRM people in a sequence, up to ${SEQUENCE_PEOPLE_PER_CALL_MAX} per call, by their CRM person ids (a person is in a sequence at most once, so the id names their run). ` +
3164
+ "stop: a run still going (" + SEQUENCE_STOPPABLE_RUNS + ") ends, stopped by the API key's user, and keeps its history. It ends THIS sequence only: the person's runs in other sequences go on. pause: a run that is ready or active holds where it is. resume: a paused run goes on " + SEQUENCE_RESUME_CHECK_RULE + ". " +
3165
+ "Answers { action, changed, results }: results has one { crm_person_id, result, status } per id sent, in order; result is stopped, paused or resumed when their run changed, not_in_sequence when they have no run in it, or not_applicable when their run does not take the action (a person still in Review stopped, an ended run paused, a run that is not paused resumed); status is their run's status now (null when they have none); changed counts the runs that changed. " +
3166
+ "WHEN TO USE: \"stop the sequence for Jane\", \"pause everyone at Acme\", \"resume the people I paused\". lighthouse_sequences_remove_people takes someone out entirely, history included, or out of Review before anyone is contacted. Needs a role that may edit sequences and view people. " + SEQUENCE_NOT_FOUND_RULE,
3167
+ inputSchema: {
3168
+ sequence_id: z.string().uuid().describe("Sequence ID, from lighthouse_sequences_list"),
3169
+ crm_person_ids: z.array(z.string().uuid()).min(1).max(SEQUENCE_PEOPLE_PER_CALL_MAX).describe(`CRM person record IDs (max ${SEQUENCE_PEOPLE_PER_CALL_MAX})`),
3170
+ action: z.enum(["stop", "pause", "resume"]).describe("stop ends their run in this sequence only (its history kept), pause holds it, resume continues a paused run " + SEQUENCE_RESUME_CHECK_RULE),
3171
+ },
3172
+ }, async (params) => {
3173
+ try {
3174
+ const { sequence_id, ...body } = params;
3175
+ const res = await api.patch(`/sequences/${sequence_id}/people`, body);
3176
+ return text(res);
3177
+ }
3178
+ catch (e) {
3179
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
3180
+ }
3181
+ });
3182
+ server.registerTool("lighthouse_sequences_remove_people", {
3183
+ title: "Remove People from Sequence",
3184
+ annotations: DESTRUCTIVE,
3185
+ description: `Remove CRM people from a sequence, up to ${SEQUENCE_PEOPLE_PER_CALL_MAX} per call, by their CRM person ids: their run in it is DELETED together with its history, at any stage, Review included, and cannot be brought back. ` +
3186
+ "lighthouse_sequences_update_people with action stop ends a run and keeps its history. " +
3187
+ "Answers { deleted: true, removed, results }: results has one { crm_person_id, result } per id sent, in order, result being removed or not_in_sequence; removed counts the runs deleted. " +
3188
+ "WHEN TO USE: the user asks to take someone out of a sequence altogether, to clear people out of its Review before anyone is contacted, or to undo an add. Needs a role that may edit sequences and view people. " + SEQUENCE_NOT_FOUND_RULE,
3189
+ inputSchema: {
3190
+ sequence_id: z.string().uuid().describe("Sequence ID, from lighthouse_sequences_list"),
3191
+ crm_person_ids: z.array(z.string().uuid()).min(1).max(SEQUENCE_PEOPLE_PER_CALL_MAX).describe(`CRM person record IDs to remove (max ${SEQUENCE_PEOPLE_PER_CALL_MAX})`),
3192
+ },
3193
+ }, async (params) => {
3194
+ try {
3195
+ const { sequence_id, ...body } = params;
3196
+ const res = await api.delete(`/sequences/${sequence_id}/people`, body);
3197
+ return text(res);
3198
+ }
3199
+ catch (e) {
3200
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
3201
+ }
3202
+ });
3203
+ server.registerTool("lighthouse_crm_list_record_sequences", {
3204
+ title: "List Record Sequences",
3205
+ annotations: READ_ONLY,
3206
+ description: "The sequence runs ONE CRM person, company or deal reaches (the record's Sequences tab), runs still going first, then the latest activity: a person's own runs; for a company, the runs that name it as the person's company and the runs of the people on its current team; for a deal, its people's runs and the runs of its companies. A custom object reaches no sequence. " +
3207
+ "Each run: " + SEQUENCE_PERSON_KEYS + ", sequence { id, name, status }, step_count, reached_through (how the record reaches the run: any of person, company, team, deal and deal_company), at_company_now (on a company: whether the person is still on its current team; null on a person or a deal), last_sent_at (the last message or invitation that went out), last_reply_at (their last reply; an out-of-office or a delivery notice never counts) and added_by (the member who added them). Who and when only, never a message. " +
3208
+ SEQUENCE_PERSON_STATUS + " " +
3209
+ "meta.counts { live, ended } counts both groups whatever group asks. " +
3210
+ "WHEN TO USE: \"which sequences is Jane in\", \"is anyone at Acme being contacted\", \"when did we last write to them\", \"who added them\". For a quick yes or no on one person, the sequences field of lighthouse_crm_get_record already lists theirs; for a SET of records (everyone in a sequence, the companies whose team is in one) use one lighthouse_crm_search_records call with a sequences condition (associated_people.sequences on companies and deals) rather than reading records one by one. " +
3211
+ "Only the sequences the API key's user can open are read: a run in any other sequence is left out and not counted, so an empty answer means none they can see. A person can have more than one CRM record, each with its own runs. Needs a role that may view sequences, people and the record's type. A record that does not exist answers 'Record not found'.",
3212
+ inputSchema: {
3213
+ record_type: z.enum(["person", "company", "deal"]).describe("person, company or deal (a custom object reaches no sequence)"),
3214
+ record_id: z.string().uuid().describe("The record's ID"),
3215
+ group: z.enum(["live", "ended"]).optional().describe("live: still going (Review included); ended: over. Both when omitted"),
3216
+ limit: z.number().min(1).max(100).default(25).describe("Runs per page (max 100)"),
3217
+ offset: z.number().min(0).default(0).describe("Runs to skip; meta.total and meta.has_more say whether more remain"),
3218
+ },
3219
+ }, async ({ record_type, record_id, group, limit, offset }) => {
3220
+ try {
3221
+ const res = await api.get(`/records/${record_type}/${record_id}/sequences`, { group, limit, offset });
3222
+ return text(res);
3223
+ }
3224
+ catch (e) {
3225
+ return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
3226
+ }
3227
+ });
3042
3228
  server.registerTool("lighthouse_inbox_email_accounts", {
3043
3229
  title: "List Email Accounts",
3044
3230
  annotations: READ_ONLY,
@@ -3098,14 +3284,14 @@ export function createServer(apiKey, options = {}) {
3098
3284
  "a CRM record (person = their email addresses, company = its domains); emails / domains (comma separated lists); " +
3099
3285
  "query (matches WORDS in any order; a value in quotes stays a phrase); " +
3100
3286
  "account_id; after / before dates (a date-only before includes that whole day); is_read. No filters = most recent threads. " +
3101
- "Great for due diligence: pull all correspondence with a company via record_id or domains, then read each thread. " +
3287
+ "record_id or domains cover all correspondence with a company. " +
3102
3288
  "Each row: { id, subject, date (the thread's latest activity), is_read, is_starred, has_attachments, has_drafts, " +
3103
3289
  "message_count (messages, never drafts; null when there are none), participants [{ name, email }] (the OTHER people, " +
3104
3290
  "the account's own address left out), last_message { id, date, direction (sent | received | draft), from { name, email }, snippet } " +
3105
3291
  "(the preview lives here, there is none at the row's root), account { id, name, email, is_owner } (the mailbox it was read through) }. " +
3106
3292
  "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 " +
3107
3293
  "are account-specific: read one with lighthouse_inbox_email_thread, passing the row's account.id. " +
3108
- "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. " +
3294
+ "meta.accounts holds one entry per account the user can see, { id, name, email, is_owner, ok, error, count }, which shows per-account failures. " +
3109
3295
  "meta.note explains an empty page (a record with no addresses, no mailbox connected).",
3110
3296
  inputSchema: {
3111
3297
  record_id: z.string().uuid().optional().describe("CRM record ID (requires record_type)"),
@@ -3140,7 +3326,7 @@ export function createServer(apiKey, options = {}) {
3140
3326
  "body (the full HTML, null when there is none), reply_to_message_id (the message this one answers, when the mailbox states it), " +
3141
3327
  "is_read, is_starred, attachments [{ id, message_id, type (image | video | audio | file | other), filename, content_type, size }], " +
3142
3328
  "inline_images (the files embedded in the body: the same keys plus cid), account { id, name, email, is_owner } }. Every key is always present. " +
3143
- "Read text when you need what was said and body only when the markup matters. To read what an attached file SAYS, pass its id, its message_id and the message's account.id to lighthouse_inbox_email_attachment. " +
3329
+ "text is what was said and body carries the markup. lighthouse_inbox_email_attachment reads what an attached file SAYS, given its id, its message_id and the message's account.id. " +
3144
3330
  "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 " +
3145
3331
  "account-specific: BOTH thread_id AND account_id (the thread row's account.id from " +
3146
3332
  "lighthouse_inbox_email_threads) are required. " +
@@ -3176,7 +3362,7 @@ export function createServer(apiKey, options = {}) {
3176
3362
  annotations: READ_ONLY,
3177
3363
  description: "What one email attachment SAYS, as text: PDFs, Word, Excel and PowerPoint files (and their OpenDocument versions), text, CSV and HTML. " +
3178
3364
  "WHEN TO USE: the user asks what an attached file contains (\"summarise the report Jane sent\", \"what are the numbers in the attached model\", \"check the attachment on that email\"). " +
3179
- "Find the message first: lighthouse_inbox_email_threads (a subject, a sender, a date), then lighthouse_inbox_email_thread, whose messages carry attachments [{ id, message_id, filename, ... }] and account { id }. Pass that attachment's id as attachment_id, its message_id, and the message's account.id as account_id. " +
3365
+ "lighthouse_inbox_email_threads finds the message (a subject, a sender, a date) and lighthouse_inbox_email_thread carries its attachments [{ id, message_id, filename, ... }] and account { id }. This takes that attachment's id as attachment_id, its message_id, and the message's account.id as account_id. " +
3180
3366
  "Returns { attachment { id, message_id, type, filename, content_type, size }, message { id, thread_id, subject }, account { id, name, email, is_owner }, kind, pages_total, text, reason, note }. " +
3181
3367
  FILE_CONTENT_RULES +
3182
3368
  " An attachment in an account the user may see only the metadata of answers not found.",
@@ -3216,7 +3402,7 @@ export function createServer(apiKey, options = {}) {
3216
3402
  "To draft a reply, pass reply_to_message_id (a MESSAGE id from lighthouse_inbox_email_thread, " +
3217
3403
  "not a thread id) plus thread_id, and set the recipients explicitly: a reply does not inherit them. " +
3218
3404
  "Write the message as text (plain words, kept as lines and paragraphs) or as body (HTML), never both. " +
3219
- "Attachments are not supported here; use the REST API for those. " +
3405
+ "Attachments are not supported here; the REST API handles those. " +
3220
3406
  "Returns the saved draft as a message with direction 'draft', the same key set a message has: " +
3221
3407
  "{ id, thread_id, date, direction, from, to, cc, bcc, reply_to, subject, snippet, text, body, reply_to_message_id, " +
3222
3408
  "is_read, is_starred, attachments, inline_images, account }.",
@@ -3226,8 +3412,8 @@ export function createServer(apiKey, options = {}) {
3226
3412
  cc: RECIPIENTS.optional().describe("Cc recipients: an address, a { name, email } object, or an array of either"),
3227
3413
  bcc: RECIPIENTS.optional().describe("Bcc recipients: an address, a { name, email } object, or an array of either"),
3228
3414
  subject: z.string().optional().describe("Subject line"),
3229
- text: z.string().optional().describe("Message as plain words; lines and paragraphs are kept. Use this or body, never both"),
3230
- body: z.string().optional().describe("Message body as HTML. Use this or text, never both"),
3415
+ text: z.string().optional().describe("Message as plain words; lines and paragraphs are kept. Either this or body, not both"),
3416
+ body: z.string().optional().describe("Message body as HTML. Either this or text, not both"),
3231
3417
  thread_id: z.string().optional().describe("Save the draft inside this existing thread (id from lighthouse_inbox_email_threads)"),
3232
3418
  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"),
3233
3419
  },
@@ -3253,9 +3439,9 @@ export function createServer(apiKey, options = {}) {
3253
3439
  "{ name, linkedin (bare handle or null), linkedin_url, avatar_url, headline, connection_degree " +
3254
3440
  "(first | second | third | out_of_network | self | null) }), " +
3255
3441
  "last_message { id, date, direction (sent | received), from (the same person object), text }, " +
3256
- "account { id, name, email (always null on LinkedIn), is_owner } }. Use " +
3257
- "lighthouse_inbox_linkedin_chat_messages with a chat id to read a conversation. Great for due diligence: " +
3258
- "filter chats by record_id or company_linkedin, then read each conversation. WHEN TO USE: 'what did we " +
3442
+ "account { id, name, email (always null on LinkedIn), is_owner } }. " +
3443
+ "lighthouse_inbox_linkedin_chat_messages reads a conversation by chat id. " +
3444
+ "record_id and company_linkedin narrow the chats to one company. WHEN TO USE: 'what did we " +
3259
3445
  "discuss with this founder on LinkedIn', 'LinkedIn conversations with anyone at Acme', 'my unread LinkedIn " +
3260
3446
  "messages' (is_read false). Whether a chat sits in the user's own account or a colleague's is its " +
3261
3447
  "account.is_owner, and account.id matches lighthouse_inbox_linkedin_accounts.",
@@ -3353,9 +3539,9 @@ export function createServer(apiKey, options = {}) {
3353
3539
  annotations: READ_ONLY,
3354
3540
  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). " +
3355
3541
  "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. " +
3356
- "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. " +
3542
+ "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\". What was DISCUSSED about a topic is in the notes of the likely meetings. " +
3357
3543
  "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 read them) }. " +
3358
- "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. " +
3544
+ "A row with meeting_key is a recorded meeting: lighthouse_inbox_calendar_meeting_notes takes it and answers what was discussed and decided, lighthouse_inbox_calendar_meeting_transcript what was said. " +
3359
3545
  "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).",
3360
3546
  inputSchema: {
3361
3547
  record_id: z.string().uuid().optional().describe("Meetings with this CRM record's people (requires record_type)"),
@@ -3402,8 +3588,8 @@ export function createServer(apiKey, options = {}) {
3402
3588
  server.registerTool("lighthouse_inbox_calendar_meeting_notes", {
3403
3589
  title: "Get Meeting Notes",
3404
3590
  annotations: READ_ONLY,
3405
- description: "The meeting notes written from one recorded meeting, by meeting_key (" + MEETING_KEY_SOURCES + "): the note in the same shape as lighthouse_crm_get_note (type 'meeting', with the meeting object and the records it is linked to that you can view), 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). " +
3406
- "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. " +
3591
+ description: "The meeting notes written from one recorded meeting, by meeting_key (" + MEETING_KEY_SOURCES + "): the note in the same shape as lighthouse_crm_get_note (type 'meeting', with the meeting object and the records it is linked to that you can view), plus content_markdown (what was generated: Meeting Context first, then the sections, then Next steps last) and is_edited (true when a member changed the note since, in which case content is the version to trust). " +
3592
+ "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, which is only needed when the answer depends on exactly what was said. " +
3407
3593
  "Not found when the meeting has no notes yet (see has_notes on the meeting) or the API key's user may not read them; for a recurring meeting the newest occurrence the user may read is returned. " + MEETING_ACCESS_RULE,
3408
3594
  inputSchema: {
3409
3595
  meeting_key: z.string().describe("The meeting_key of a recorded meeting, " + MEETING_KEY_SOURCES),
@@ -3422,7 +3608,7 @@ export function createServer(apiKey, options = {}) {
3422
3608
  annotations: READ_ONLY,
3423
3609
  description: "The transcript of one recorded meeting by meeting_key (" + MEETING_KEY_SOURCES + "), as text: one \"Speaker: text\" line per utterance, in order. A note's meeting_key opens this transcript when that note's meeting.has_transcript is true. " +
3424
3610
  "WHEN TO USE: only when the answer depends on what was actually SAID (an exact quote, a number, who said what, a nuance the notes would flatten). For what was discussed or decided, lighthouse_inbox_calendar_meeting_notes is enough and far smaller. " +
3425
- `A long meeting comes back in windows of max_chars (default ${TRANSCRIPT_WINDOW_CHARS}): meta.has_more true means the meeting continues past this window, so pass meta.next_offset_chars as offset_chars to read on, and never treat a window with has_more as the end of the meeting. ` +
3611
+ `A long meeting comes back in windows of max_chars (default ${TRANSCRIPT_WINDOW_CHARS}): meta.has_more true means the meeting continues past this window, meta.next_offset_chars sent as offset_chars reads on from there, and a window with has_more is not the end of the meeting. ` +
3426
3612
  "Not found when the meeting has no transcript or the API key's user may not read it. " + MEETING_ACCESS_RULE,
3427
3613
  inputSchema: {
3428
3614
  meeting_key: z.string().describe("The meeting_key of a recorded meeting, " + MEETING_KEY_SOURCES),
@@ -3438,71 +3624,5 @@ export function createServer(apiKey, options = {}) {
3438
3624
  return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
3439
3625
  }
3440
3626
  });
3441
- if (options.privateEquity !== true)
3442
- return server;
3443
- server.registerTool("lighthouse_private_equity_list_saved_searches", {
3444
- title: "List Private Equity Saved Searches",
3445
- annotations: READ_ONLY,
3446
- description: "List the Private Equity saved searches the user can see in this workspace (their own, the ones shared with the workspace, and the ones shared with one of their teams), ordered by name. " +
3447
- "Each search carries id, name, sharing (private | workspace), shared_with_teams, is_owner, owner, created_at, updated_at, last_run_at and last_run: the stored run its results come from ({ id, created_at, last_fetched_at, result_count, match_count, created_by }, where result_count is how many companies are stored and match_count how many the search matched in the company database, or null), or null when it never ran. The search's filters are not returned. " +
3448
- "READ ONLY: this reads what the workspace already stored. It never runs a search, never fetches new companies and never spends credits; a Private Equity search is run, and paid for, only in the Lighthouse app. " +
3449
- "WHEN TO USE: the user asks which Private Equity searches exist, or names one whose results they want; pass its id to lighthouse_private_equity_list_saved_search_results. " +
3450
- "These are separate from the Discovery saved searches (lighthouse_discovery_list_saved_searches), which search a different database.",
3451
- inputSchema: {
3452
- offset: z.number().int().min(0).optional().describe("Number of saved searches to skip (default 0)"),
3453
- limit: z.number().int().min(1).max(100).optional().describe("Saved searches per page, 1 to 100 (default 25)"),
3454
- },
3455
- }, async ({ offset, limit }) => {
3456
- try {
3457
- const res = await api.get("/private-equity/saved-searches", { offset, limit });
3458
- return text(res);
3459
- }
3460
- catch (e) {
3461
- return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
3462
- }
3463
- });
3464
- server.registerTool("lighthouse_private_equity_list_saved_search_results", {
3465
- title: "List Private Equity Saved Search Results",
3466
- annotations: READ_ONLY,
3467
- description: "Read one page of the companies a Private Equity saved search's LAST run stored, in the order that run returned them. " +
3468
- "Each row is a stored company: id, name, domain, website_url, linkedin_url, short_description, avatar_url, hq ({ country, country_code, region, city }) and hq_label, industry, category, sector, naics ({ code, label }), business_models, employees and revenue_usd ({ value, estimated }), founded_year, company_type, num_locations, crm_id (the id of this workspace's CRM company for it, or null), lighthouse_company (the matching company in Lighthouse's own data, or null), from_lighthouse (the keys whose value was filled from Lighthouse's own data), fetched_at, profile_updated_at, and match ({ snippets, headline }: why a keyword or product search matched it, or null). " +
3469
- "meta carries total, limit, offset, has_more, has_results and run (the stored run the page is read from). A search that never ran, or whose last run is no longer stored, answers an empty page with meta.has_results false. " +
3470
- "READ ONLY: the rows are exactly what the search stored when someone last ran it in the app, even if its filters were edited since. This never runs the search again, never fetches more companies and never spends credits; for newer results, ask the user to run the search in the Lighthouse app. " +
3471
- "WHEN TO USE: the user asks what a Private Equity search found. Page with offset while meta.has_more is true. For one company's full stored profile use lighthouse_private_equity_get_company; when crm_id is set, lighthouse_crm_get_record (record_type 'company') opens the CRM company. " +
3472
- "Not found when the saved search does not exist or the user cannot see it.",
3473
- inputSchema: {
3474
- search_id: z.string().uuid().describe("Private Equity saved search ID (from lighthouse_private_equity_list_saved_searches)"),
3475
- offset: z.number().int().min(0).optional().describe("Number of companies to skip (default 0)"),
3476
- limit: z.number().int().min(1).max(100).optional().describe("Companies per page, 1 to 100 (default 25)"),
3477
- },
3478
- }, async ({ search_id, offset, limit }) => {
3479
- try {
3480
- const res = await api.get(`/private-equity/saved-searches/${encodeURIComponent(search_id)}/results`, { offset, limit });
3481
- return text(res);
3482
- }
3483
- catch (e) {
3484
- return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
3485
- }
3486
- });
3487
- server.registerTool("lighthouse_private_equity_get_company", {
3488
- title: "Get Private Equity Company",
3489
- annotations: READ_ONLY,
3490
- description: "Read one company's stored Private Equity profile by company_id: the id of a row returned by lighthouse_private_equity_list_saved_search_results (a text id, not a UUID, and not a Discovery or CRM id). " +
3491
- "Returns the keys of a results row (without match) plus the full profile: legal_names, commercial_names, long_description, long_description_extracted, locations, classification ({ naics_secondary, nace, sic, isic }), target_markets, core_offerings, technology_focus, certification_focus, supply_chain_focus, business_tags, technologies and technologies_total, contact ({ primary_phone, phone_numbers, primary_email, emails }), socials, website_language, profile_created_at, is_fresh and run_count. " +
3492
- "READ ONLY: the profile as a Private Equity search of this workspace stored it, never refreshed. It never fetches the company again and never spends credits. " +
3493
- "WHEN TO USE: the user asks for details on one company from a Private Equity search's results. When crm_id is set, lighthouse_crm_get_record (record_type 'company') opens the CRM company. " +
3494
- "Not found when none of this workspace's Private Equity searches returned that company.",
3495
- inputSchema: {
3496
- company_id: z.string().min(1).describe("The company's id, as a row of lighthouse_private_equity_list_saved_search_results gives it"),
3497
- },
3498
- }, async ({ company_id }) => {
3499
- try {
3500
- const res = await api.get(`/private-equity/companies/${encodeURIComponent(company_id)}`);
3501
- return text(res);
3502
- }
3503
- catch (e) {
3504
- return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
3505
- }
3506
- });
3507
3627
  return server;
3508
3628
  }