@trylighthouse/mcp-server 0.2.3 → 0.2.10
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/README.md +9 -1
- package/build/api.d.ts +34 -0
- package/build/api.js +146 -12
- package/build/server.d.ts +4 -1
- package/build/server.js +308 -72
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -86,7 +86,7 @@ Records support built-in types (`company`, `person`, `deal`) and custom object t
|
|
|
86
86
|
|
|
87
87
|
| Tool | Description |
|
|
88
88
|
|------|-------------|
|
|
89
|
-
| `lighthouse_crm_list_lists` | Get
|
|
89
|
+
| `lighthouse_crm_list_lists` | Get the CRM lists in your workspace, or find one by its name |
|
|
90
90
|
| `lighthouse_crm_create_list` | Create a new list |
|
|
91
91
|
| `lighthouse_crm_get_list` | Get a single list |
|
|
92
92
|
| `lighthouse_crm_update_list` | Update a list |
|
|
@@ -164,6 +164,10 @@ Files and folders live in the workspace, not on a record. Linking one to a recor
|
|
|
164
164
|
| `lighthouse_workspace_get_user` | Get a workspace user |
|
|
165
165
|
| `lighthouse_workspace_list_teams` | List workspace teams |
|
|
166
166
|
| `lighthouse_workspace_get_team` | Get a workspace team |
|
|
167
|
+
| `lighthouse_workspace_list_webhook_subscriptions` | List your webhook subscriptions (read only) |
|
|
168
|
+
| `lighthouse_workspace_get_webhook_subscription` | Get one of your webhook subscriptions (read only) |
|
|
169
|
+
|
|
170
|
+
Webhook subscriptions are created and deleted with the REST API or the SDKs, with an API key.
|
|
167
171
|
|
|
168
172
|
### Discovery
|
|
169
173
|
|
|
@@ -201,6 +205,10 @@ Files and folders live in the workspace, not on a record. Linking one to a recor
|
|
|
201
205
|
| `lighthouse_reports_share_dashboard_with_teams` | Share a dashboard with teams |
|
|
202
206
|
| `lighthouse_reports_revoke_dashboard_from_teams` | Revoke team access to a dashboard |
|
|
203
207
|
|
|
208
|
+
## Rate limits
|
|
209
|
+
|
|
210
|
+
A `429` with the code `rate_limit_exceeded` is retried for you, up to 4 times, before the tool reports the `429` exactly as the API sent it. Before each retry the server waits at least the seconds in the `Retry-After` header (1 second if there is none, at most 30). Each retry also draws a random wait that can grow with every attempt: up to 2 seconds before the first retry, then up to 4, 8 and 16 (never more than 30), and the server waits the longer of the two. Calls refused at the same moment therefore spread out instead of all coming back together and being refused again. It never starts a retry more than 40 seconds after the first attempt, so the API still has time to answer within the 60 seconds many MCP clients allow for a tool call. When the next wait would end past those 40 seconds, the tool reports the `429` straight away. A tool call your client cancels stops waiting and is not sent again. The API refuses such a request before running it, so a refused attempt changes nothing and only the attempt it accepts is applied. No other error is retried, `credit_limit_exceeded` included, and neither is a limit set by a connected email or LinkedIn account (`rate_limited`).
|
|
211
|
+
|
|
204
212
|
## Environment Variables
|
|
205
213
|
|
|
206
214
|
| Variable | Required | Description |
|
package/build/api.d.ts
CHANGED
|
@@ -1,9 +1,34 @@
|
|
|
1
|
+
export declare const RATE_LIMIT_RETRY: Readonly<{
|
|
2
|
+
retriesMax: 4;
|
|
3
|
+
fallbackWaitMs: 1000;
|
|
4
|
+
waitMaxMs: 30000;
|
|
5
|
+
backoffBaseMs: 1000;
|
|
6
|
+
retryWindowMs: 40000;
|
|
7
|
+
}>;
|
|
8
|
+
export declare const RATE_LIMIT_CODE = "rate_limit_exceeded";
|
|
9
|
+
export interface RateLimitRetryOptions {
|
|
10
|
+
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
|
|
11
|
+
random?: () => number;
|
|
12
|
+
now?: () => number;
|
|
13
|
+
signal?: AbortSignal;
|
|
14
|
+
}
|
|
15
|
+
export declare function withCallSignal<T>(signal: AbortSignal | undefined, run: () => T): T;
|
|
16
|
+
export declare function callSignalOf(args: unknown[]): AbortSignal | undefined;
|
|
17
|
+
export declare function retryAfterMs(header: string | null | undefined, now?: number): number;
|
|
18
|
+
export declare function backoffCeilingMs(retry: number): number;
|
|
19
|
+
export declare function rateLimitWaitMs(header: string | null | undefined, retry: number, random?: () => number, now?: number): number;
|
|
20
|
+
export declare function isRateLimitRefusal(res: Response): Promise<boolean>;
|
|
21
|
+
export declare function fetchWithRateLimitRetry(send: () => Promise<Response>, options?: RateLimitRetryOptions): Promise<Response>;
|
|
1
22
|
export declare class LighthouseAPI {
|
|
2
23
|
private baseUrl;
|
|
3
24
|
private apiKey;
|
|
4
25
|
private currentTool;
|
|
5
26
|
constructor(apiKey?: string);
|
|
6
27
|
setCurrentTool(toolName: string | null): void;
|
|
28
|
+
private url;
|
|
29
|
+
private headers;
|
|
30
|
+
private query;
|
|
31
|
+
private send;
|
|
7
32
|
private request;
|
|
8
33
|
get(path: string, params?: Record<string, unknown>): Promise<{
|
|
9
34
|
status: number;
|
|
@@ -11,6 +36,15 @@ export declare class LighthouseAPI {
|
|
|
11
36
|
error: unknown;
|
|
12
37
|
meta?: unknown;
|
|
13
38
|
}>;
|
|
39
|
+
download(path: string, params: Record<string, unknown> | undefined, maxBytes: number): Promise<{
|
|
40
|
+
bytes: Buffer<ArrayBuffer>;
|
|
41
|
+
contentType: string;
|
|
42
|
+
} | null>;
|
|
43
|
+
downloadSigned(url: string, maxBytes: number): Promise<{
|
|
44
|
+
bytes: Buffer<ArrayBuffer>;
|
|
45
|
+
contentType: string;
|
|
46
|
+
} | null>;
|
|
47
|
+
private fetchBytes;
|
|
14
48
|
post(path: string, body?: Record<string, unknown>): Promise<{
|
|
15
49
|
status: number;
|
|
16
50
|
data: unknown;
|
package/build/api.js
CHANGED
|
@@ -1,4 +1,103 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
1
2
|
const DEFAULT_BASE_URL = "https://api.trylighthouse.vc";
|
|
3
|
+
export const RATE_LIMIT_RETRY = Object.freeze({
|
|
4
|
+
retriesMax: 4,
|
|
5
|
+
fallbackWaitMs: 1_000,
|
|
6
|
+
waitMaxMs: 30_000,
|
|
7
|
+
backoffBaseMs: 1_000,
|
|
8
|
+
retryWindowMs: 40_000,
|
|
9
|
+
});
|
|
10
|
+
export const RATE_LIMIT_CODE = "rate_limit_exceeded";
|
|
11
|
+
const toolCall = new AsyncLocalStorage();
|
|
12
|
+
export function withCallSignal(signal, run) {
|
|
13
|
+
return toolCall.run({ signal }, run);
|
|
14
|
+
}
|
|
15
|
+
export function callSignalOf(args) {
|
|
16
|
+
for (let i = args.length - 1; i >= 0; i -= 1) {
|
|
17
|
+
const arg = args[i];
|
|
18
|
+
const signal = arg != null && typeof arg === "object" ? arg.signal : undefined;
|
|
19
|
+
if (signal instanceof AbortSignal)
|
|
20
|
+
return signal;
|
|
21
|
+
}
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
const pause = (ms, signal) => new Promise((resolve) => {
|
|
25
|
+
if (signal?.aborted)
|
|
26
|
+
return resolve();
|
|
27
|
+
const done = () => {
|
|
28
|
+
clearTimeout(timer);
|
|
29
|
+
signal?.removeEventListener("abort", done);
|
|
30
|
+
resolve();
|
|
31
|
+
};
|
|
32
|
+
const timer = setTimeout(done, ms);
|
|
33
|
+
signal?.addEventListener("abort", done, { once: true });
|
|
34
|
+
});
|
|
35
|
+
function cancelled(signal) {
|
|
36
|
+
const reason = signal.reason;
|
|
37
|
+
if (reason instanceof Error)
|
|
38
|
+
return reason;
|
|
39
|
+
const error = new Error("The tool call was cancelled.");
|
|
40
|
+
error.name = "AbortError";
|
|
41
|
+
return error;
|
|
42
|
+
}
|
|
43
|
+
export function retryAfterMs(header, now = Date.now()) {
|
|
44
|
+
const value = typeof header === "string" ? header.trim() : "";
|
|
45
|
+
if (/^\d+(\.\d+)?$/.test(value))
|
|
46
|
+
return Number(value) * 1000;
|
|
47
|
+
if (/[a-z]/i.test(value)) {
|
|
48
|
+
const at = Date.parse(value);
|
|
49
|
+
if (Number.isFinite(at))
|
|
50
|
+
return Math.max(0, at - now);
|
|
51
|
+
}
|
|
52
|
+
return RATE_LIMIT_RETRY.fallbackWaitMs;
|
|
53
|
+
}
|
|
54
|
+
export function backoffCeilingMs(retry) {
|
|
55
|
+
return Math.min(RATE_LIMIT_RETRY.waitMaxMs, RATE_LIMIT_RETRY.backoffBaseMs * 2 ** retry);
|
|
56
|
+
}
|
|
57
|
+
export function rateLimitWaitMs(header, retry, random = Math.random, now = Date.now()) {
|
|
58
|
+
const floor = Math.min(retryAfterMs(header, now), RATE_LIMIT_RETRY.waitMaxMs);
|
|
59
|
+
const draw = Number(random());
|
|
60
|
+
const spread = Number.isFinite(draw) ? Math.min(Math.max(draw, 0), 1) : 0;
|
|
61
|
+
return Math.ceil(Math.max(floor, spread * backoffCeilingMs(retry)));
|
|
62
|
+
}
|
|
63
|
+
export async function isRateLimitRefusal(res) {
|
|
64
|
+
if (res.status !== 429)
|
|
65
|
+
return false;
|
|
66
|
+
try {
|
|
67
|
+
const body = (await res.clone().json());
|
|
68
|
+
if (body == null || typeof body !== "object")
|
|
69
|
+
return false;
|
|
70
|
+
const error = body.error;
|
|
71
|
+
const code = error != null && typeof error === "object" ? error.code : undefined;
|
|
72
|
+
return (typeof code === "string" ? code : body.error_code) === RATE_LIMIT_CODE;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
export async function fetchWithRateLimitRetry(send, options = {}) {
|
|
79
|
+
const sleep = options.sleep ?? pause;
|
|
80
|
+
const random = options.random ?? Math.random;
|
|
81
|
+
const now = options.now ?? Date.now;
|
|
82
|
+
const signal = options.signal;
|
|
83
|
+
const startedAt = now();
|
|
84
|
+
let res = await send();
|
|
85
|
+
for (let retry = 1; retry <= RATE_LIMIT_RETRY.retriesMax; retry += 1) {
|
|
86
|
+
if (signal?.aborted)
|
|
87
|
+
return res;
|
|
88
|
+
if (!(await isRateLimitRefusal(res)))
|
|
89
|
+
return res;
|
|
90
|
+
const wait = rateLimitWaitMs(res.headers.get("retry-after"), retry, random, now());
|
|
91
|
+
if (now() - startedAt + wait > RATE_LIMIT_RETRY.retryWindowMs)
|
|
92
|
+
return res;
|
|
93
|
+
await res.body?.cancel().catch(() => undefined);
|
|
94
|
+
await sleep(wait, signal);
|
|
95
|
+
if (signal?.aborted)
|
|
96
|
+
throw cancelled(signal);
|
|
97
|
+
res = await send();
|
|
98
|
+
}
|
|
99
|
+
return res;
|
|
100
|
+
}
|
|
2
101
|
export class LighthouseAPI {
|
|
3
102
|
baseUrl;
|
|
4
103
|
apiKey;
|
|
@@ -15,9 +114,11 @@ export class LighthouseAPI {
|
|
|
15
114
|
setCurrentTool(toolName) {
|
|
16
115
|
this.currentTool = toolName;
|
|
17
116
|
}
|
|
18
|
-
|
|
117
|
+
url(path) {
|
|
19
118
|
const versioned = (/^\/v\d+\//.test(path) || path.startsWith("/mcp/")) ? path : `/v1${path}`;
|
|
20
|
-
|
|
119
|
+
return `${this.baseUrl}${versioned}`;
|
|
120
|
+
}
|
|
121
|
+
headers() {
|
|
21
122
|
const headers = {
|
|
22
123
|
Authorization: `Bearer ${this.apiKey}`,
|
|
23
124
|
"Content-Type": "application/json",
|
|
@@ -26,23 +127,56 @@ export class LighthouseAPI {
|
|
|
26
127
|
if (this.currentTool) {
|
|
27
128
|
headers["X-Lighthouse-MCP-Tool"] = this.currentTool;
|
|
28
129
|
}
|
|
29
|
-
|
|
30
|
-
method,
|
|
31
|
-
headers,
|
|
32
|
-
body: body ? JSON.stringify(body) : undefined,
|
|
33
|
-
});
|
|
34
|
-
const json = await res.json();
|
|
35
|
-
return json;
|
|
130
|
+
return headers;
|
|
36
131
|
}
|
|
37
|
-
|
|
38
|
-
|
|
132
|
+
query(params) {
|
|
133
|
+
return params
|
|
39
134
|
? "?" +
|
|
40
135
|
Object.entries(params)
|
|
41
136
|
.filter(([, v]) => v !== undefined && v !== null)
|
|
42
137
|
.map(([k, v]) => `${k}=${encodeURIComponent(typeof v === "object" ? JSON.stringify(v) : String(v))}`)
|
|
43
138
|
.join("&")
|
|
44
139
|
: "";
|
|
45
|
-
|
|
140
|
+
}
|
|
141
|
+
send(url, init) {
|
|
142
|
+
const signal = toolCall.getStore()?.signal;
|
|
143
|
+
const request = signal ? { ...init, signal } : init;
|
|
144
|
+
return fetchWithRateLimitRetry(() => fetch(url, request), { signal });
|
|
145
|
+
}
|
|
146
|
+
async request(method, path, body) {
|
|
147
|
+
const res = await this.send(this.url(path), {
|
|
148
|
+
method,
|
|
149
|
+
headers: this.headers(),
|
|
150
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
151
|
+
});
|
|
152
|
+
const json = await res.json();
|
|
153
|
+
return json;
|
|
154
|
+
}
|
|
155
|
+
async get(path, params) {
|
|
156
|
+
return this.request("GET", path + this.query(params));
|
|
157
|
+
}
|
|
158
|
+
async download(path, params, maxBytes) {
|
|
159
|
+
return this.fetchBytes(this.url(path) + this.query(params), this.headers(), maxBytes);
|
|
160
|
+
}
|
|
161
|
+
async downloadSigned(url, maxBytes) {
|
|
162
|
+
return this.fetchBytes(url, {}, maxBytes);
|
|
163
|
+
}
|
|
164
|
+
async fetchBytes(url, headers, maxBytes) {
|
|
165
|
+
try {
|
|
166
|
+
const res = await this.send(url, { headers });
|
|
167
|
+
if (!res.ok)
|
|
168
|
+
return null;
|
|
169
|
+
const declared = Number(res.headers.get("content-length"));
|
|
170
|
+
if (Number.isFinite(declared) && declared > maxBytes)
|
|
171
|
+
return null;
|
|
172
|
+
const bytes = Buffer.from(await res.arrayBuffer());
|
|
173
|
+
if (bytes.length > maxBytes)
|
|
174
|
+
return null;
|
|
175
|
+
return { bytes, contentType: (res.headers.get("content-type") || "").split(";")[0].trim().toLowerCase() };
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
46
180
|
}
|
|
47
181
|
async post(path, body) {
|
|
48
182
|
return this.request("POST", path, body);
|
package/build/server.d.ts
CHANGED
|
@@ -1,2 +1,5 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
-
export
|
|
2
|
+
export interface CreateServerOptions {
|
|
3
|
+
privateEquity?: boolean;
|
|
4
|
+
}
|
|
5
|
+
export declare function createServer(apiKey?: string, options?: CreateServerOptions): McpServer;
|
package/build/server.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import { LighthouseAPI } from "./api.js";
|
|
3
|
+
import { LighthouseAPI, callSignalOf, withCallSignal } from "./api.js";
|
|
4
4
|
import { leanDiscovery } from "./leanDiscovery.js";
|
|
5
5
|
function text(data) {
|
|
6
6
|
const failed = data != null && typeof data === "object" && data.error != null;
|
|
@@ -29,6 +29,44 @@ function profileSection(res, keys) {
|
|
|
29
29
|
return { ...envelope, data: section };
|
|
30
30
|
}
|
|
31
31
|
const TRANSCRIPT_WINDOW_CHARS = 40_000;
|
|
32
|
+
const FILE_CONTENT_WINDOW_CHARS = TRANSCRIPT_WINDOW_CHARS;
|
|
33
|
+
const IMAGE_BYTES_MAX = 5 * 1024 * 1024;
|
|
34
|
+
const IMAGE_CONTENT_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
|
|
35
|
+
const IMAGE_LIMIT_WORDS = `${IMAGE_BYTES_MAX / (1024 * 1024)} MB`;
|
|
36
|
+
function viewableImageType(res, pick) {
|
|
37
|
+
if (res == null || typeof res !== "object")
|
|
38
|
+
return null;
|
|
39
|
+
const envelope = res;
|
|
40
|
+
if (envelope.error != null || envelope.data == null || typeof envelope.data !== "object")
|
|
41
|
+
return null;
|
|
42
|
+
const data = envelope.data;
|
|
43
|
+
if (data.kind !== "image")
|
|
44
|
+
return null;
|
|
45
|
+
const { contentType, size } = pick(data);
|
|
46
|
+
const type = typeof contentType === "string" ? contentType.toLowerCase() : "";
|
|
47
|
+
if (!IMAGE_CONTENT_TYPES.has(type))
|
|
48
|
+
return null;
|
|
49
|
+
if (typeof size === "number" && size > IMAGE_BYTES_MAX)
|
|
50
|
+
return null;
|
|
51
|
+
return type;
|
|
52
|
+
}
|
|
53
|
+
function textWithImage(res, image, declaredType) {
|
|
54
|
+
const base = text(res);
|
|
55
|
+
if (!image)
|
|
56
|
+
return base;
|
|
57
|
+
const mimeType = IMAGE_CONTENT_TYPES.has(image.contentType) ? image.contentType : declaredType;
|
|
58
|
+
return { ...base, content: [...base.content, { type: "image", data: image.bytes.toString("base64"), mimeType }] };
|
|
59
|
+
}
|
|
60
|
+
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
|
+
"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. ` +
|
|
63
|
+
"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. ` +
|
|
66
|
+
"Older .doc, .xls and .ppt files, archives, audio and video cannot be read.";
|
|
67
|
+
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
|
+
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.";
|
|
32
70
|
const THREAD_MESSAGES_PAGE_DEFAULT = 15;
|
|
33
71
|
const RETIRED_INPUTS = {
|
|
34
72
|
email: "email is no longer a parameter. Use emails, a comma separated list of email addresses.",
|
|
@@ -43,18 +81,18 @@ const ADDRESS_INPUT = z.union([
|
|
|
43
81
|
}),
|
|
44
82
|
]);
|
|
45
83
|
const RECIPIENTS = z.union([ADDRESS_INPUT, z.array(ADDRESS_INPUT)]);
|
|
46
|
-
export function createServer(apiKey) {
|
|
84
|
+
export function createServer(apiKey, options = {}) {
|
|
47
85
|
const api = new LighthouseAPI(apiKey);
|
|
48
86
|
const server = new McpServer({
|
|
49
87
|
name: "lighthouse",
|
|
50
|
-
version: "0.2.
|
|
88
|
+
version: "0.2.10",
|
|
51
89
|
});
|
|
52
90
|
const originalRegister = server.registerTool.bind(server);
|
|
53
91
|
server.registerTool = (name, schemaOrConfig, handler) => {
|
|
54
92
|
const wrappedHandler = async (...args) => {
|
|
55
93
|
api.setCurrentTool(name);
|
|
56
94
|
try {
|
|
57
|
-
return await handler(...args);
|
|
95
|
+
return await withCallSignal(callSignalOf(args), () => handler(...args));
|
|
58
96
|
}
|
|
59
97
|
finally {
|
|
60
98
|
api.setCurrentTool(null);
|
|
@@ -78,7 +116,8 @@ export function createServer(apiKey) {
|
|
|
78
116
|
"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; " +
|
|
79
117
|
"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; " +
|
|
80
118
|
"domain/email/phone: contains_any, not_contains_any, contains_all, empty, not_empty; " +
|
|
81
|
-
"list_entries (the record's list memberships; the value is an array of list ids from lighthouse_crm_list_lists, and the list's record_type must be this record type): contains_any, not_contains_any, contains_all, empty, not_empty (empty = in no list, not_empty = in at least one list; both take no value). A custom field can belong to ONE list: lighthouse_crm_list_attributes
|
|
119
|
+
"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; " +
|
|
120
|
+
"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); " +
|
|
82
121
|
"checkbox: eq (value is true or false); " +
|
|
83
122
|
"interaction (first/last email & linkedin interaction, overall interaction-with): eq, neq, empty, not_empty; " +
|
|
84
123
|
"interaction-type (first/last interaction type): contains_any, not_contains_any, empty, not_empty. " +
|
|
@@ -100,16 +139,32 @@ export function createServer(apiKey) {
|
|
|
100
139
|
"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. " +
|
|
101
140
|
"Use the EXACT field keys and option values returned by lighthouse_crm_list_attributes / lighthouse_crm_list_options, never abbreviate or invent them, the chosen values for a select/multi_select condition MUST be a subset of that field's options. If a requested concept has no matching field or option (e.g. a 'competitor'/'portfolio' status that no field actually holds), TELL THE USER rather than dropping the clause or mapping it onto an unrelated field. " +
|
|
102
141
|
"Do NOT use discovery field keys here, CRM fields come from lighthouse_crm_list_attributes. " +
|
|
103
|
-
"RESPONSE VALIDATION META:
|
|
104
|
-
"`ignored_filter_fields` (field keys not
|
|
142
|
+
"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): " +
|
|
143
|
+
"`ignored_filter_fields` (field keys not recognised; that condition was dropped), " +
|
|
105
144
|
"`invalid_operators` ([{field, operator}] where the operator is not valid for that field's type; that condition was dropped), and " +
|
|
106
145
|
"`invalid_logical_operators` ([{operator}] where a group's join was not AND/OR; treated as AND). " +
|
|
107
|
-
"If ANY are non-empty, part of your filter did NOT apply (results are broader/narrower than intended): fix the offending field/operator and retry, or tell the user which clauses could not be applied. A `warnings` array
|
|
146
|
+
"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. " +
|
|
147
|
+
"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. " +
|
|
148
|
+
"The saved filters of a view_id are checked the same way, as if you had sent them. " +
|
|
149
|
+
"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.";
|
|
150
|
+
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.";
|
|
151
|
+
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.";
|
|
152
|
+
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.";
|
|
153
|
+
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.";
|
|
154
|
+
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.";
|
|
155
|
+
const TASK_UNVIEWABLE_LINKS_KEPT = "Links of such a type already on the task are kept as they are.";
|
|
156
|
+
const UNLINK_NOT_VIEWABLE_KEPT = "A link to a record of a type you cannot view, or of a custom object you cannot open, is left as it is, exactly like an id that is not linked to the note: the call still answers 200 with the note.";
|
|
157
|
+
const LINKED_RECORDS_LEFT_OUT = "A record of a type you cannot view, or of a custom object you cannot open, is left out.";
|
|
158
|
+
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.";
|
|
159
|
+
const REPORT_ROLE_SOURCES = "notes, tasks or a meeting transcript their role does not let them read";
|
|
160
|
+
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.";
|
|
161
|
+
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.";
|
|
162
|
+
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.`;
|
|
108
163
|
const CRM_SORT_DESCRIPTION = 'CRM sort array. Format: [{"field":"<key>","direction":"asc"|"desc"}]. ' +
|
|
109
164
|
"Multiple entries sort by the first, then tie-break by the next. " +
|
|
110
165
|
"NOT every field is sortable. A field is sortable ONLY when its type (the `data_type` returned by lighthouse_crm_list_attributes) is one of: " +
|
|
111
166
|
"text, url, number, currency, percentage, rating, date, datetime, datetimez, select, status, user, checkbox, " +
|
|
112
|
-
"record, record_company_name, record_person_name, record_deal_name, growth, note, duration. " +
|
|
167
|
+
"record, record_company_name, record_person_name, record_deal_name, record_custom_object_name, growth, note, duration. " +
|
|
113
168
|
"growth (headcount_growth) sorts by ONE window: add \"timeframe\":\"1m\"|\"3m\"|\"6m\"|\"1y\" to the sort item ({\"field\":\"headcount_growth\",\"direction\":\"desc\",\"timeframe\":\"6m\"}; 3m when omitted; an unknown window comes back in invalid_sort_fields). " +
|
|
114
169
|
"note (the notes field) orders by the newest note's date, records without notes last. " +
|
|
115
170
|
"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. " +
|
|
@@ -137,7 +192,7 @@ export function createServer(apiKey) {
|
|
|
137
192
|
"investor_multi_select: contains_any, not_contains_any, contains_all, empty, not_empty " +
|
|
138
193
|
"(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); " +
|
|
139
194
|
"work_experience_multi_select: contains_any, not_contains_any, contains_all, empty, not_empty " +
|
|
140
|
-
"(value is array of
|
|
195
|
+
"(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); " +
|
|
141
196
|
"education_multi_select: contains_any, not_contains_any, contains_all, empty, not_empty " +
|
|
142
197
|
"(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); " +
|
|
143
198
|
"funding_type_multi_select: contains_any, not_contains_any, empty, not_empty; " +
|
|
@@ -147,7 +202,7 @@ export function createServer(apiKey) {
|
|
|
147
202
|
"SPECIAL COMPOUND TYPES, " +
|
|
148
203
|
"signal_select (company_signals): operators contains_any/not_contains_any/contains_all, value is array of signal strings e.g. [\"new_funding_round\",\"growth_headcount\"]; " +
|
|
149
204
|
"signal_select (person_signals): operators contains_any/not_contains_any/contains_all, value is {\"signals\":[\"company_registration\",...],\"registration_countries\":[\"DE\",\"GB\",...],\"signal_dates\":{\"stealth\":\"2024-03-01\"},\"stealth_predicted_areas\":[\"fintech\",...]} " +
|
|
150
|
-
"(every key besides signals is optional: registration_countries only filters the company_registration signal by country, as UPPERCASE ISO codes, and the country is where the company was registered, and registrations exist only for the countries whose company registers we read
|
|
205
|
+
"(every key besides signals is optional: registration_countries only filters the company_registration signal by country, as UPPERCASE ISO codes, and the country is where the company was registered, and registrations exist only for the countries whose company registers we read, which lighthouse_discovery_list_locations returns as `registration_countries`, so any other country returns no people; " +
|
|
151
206
|
"signal_dates maps a signal to a 'started on/after' ISO date and applies to stealth / out_of_stealth / founded_new_company; " +
|
|
152
207
|
"stealth_predicted_areas refines the stealth signal by predicted category group, same values as person_stealth_predicted_area); " +
|
|
153
208
|
"language_select: operators contains_any/not_contains_any/contains_all, value is {\"languages\":[<ISO 639-1 two-letter codes, e.g. \"en\",\"de\",\"fr\">],\"proficiency\":[<one or more of exactly \"NATIVE_BILINGUAL\",\"EXPERT\",\"ADVANCED\",\"INTERMEDIATE\",\"BASIC\">]} " +
|
|
@@ -157,14 +212,14 @@ export function createServer(apiKey) {
|
|
|
157
212
|
"date ops → ISO date string \"YYYY-MM-DD\". " +
|
|
158
213
|
"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. " +
|
|
159
214
|
"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. " +
|
|
160
|
-
"Use the company_*/person_* keys listed below VERBATIM, do not invent variants (no _usd suffix, no abbreviation like hq for headquarter) and do not use the bare permalinks returned by lighthouse_discovery_list_attributes; any
|
|
215
|
+
"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. " +
|
|
161
216
|
"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. " +
|
|
162
217
|
"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. " +
|
|
163
218
|
"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): " +
|
|
164
|
-
"`ignored_filter_fields` (keys not
|
|
219
|
+
"`ignored_filter_fields` (keys not recognised; that condition was dropped), " +
|
|
165
220
|
"`invalid_operators` ([{field, operator}] where the operator is not valid for that field's type; that condition was dropped), and " +
|
|
166
221
|
"`invalid_logical_operators` ([{operator}] where a group's join was not AND/OR; treated as AND). " +
|
|
167
|
-
"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
|
|
222
|
+
"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.";
|
|
168
223
|
const COMPANY_DISCOVERY_SORT_DESCRIPTION = 'Discovery company sort. Format: {"by":"<field>","dir":"asc"|"desc"}. ' +
|
|
169
224
|
"ONLY two fields are sortable: founded_on (founding date) and total_funding_amount_usd (total funding raised, in USD). " +
|
|
170
225
|
"Omit sort to use the default: relevance score, descending. " +
|
|
@@ -182,7 +237,7 @@ export function createServer(apiKey) {
|
|
|
182
237
|
"Socials: company_linkedin, company_twitter, company_facebook, company_github, company_producthunt (url; matches the profile handle/permalink with text operators). " +
|
|
183
238
|
"Investors (who invested IN this company): company_investor (investor_multi_select; ids from lighthouse_discovery_search_entities record_type 'investor'), company_investor_country (multi_select; list_locations countries), company_investor_region (multi_select; list_locations regions). " +
|
|
184
239
|
"Investments (the company AS an investor; the investments_top_* keys match the investor's TOP-RANKED preference only, e.g. their #1 country, not every investment they ever made): company_is_investor (checkbox; eq true = the company is itself an investor such as a fund or CVC, eq false = exclude investors), company_num_investments (number; total recorded investments), company_investments_top_category (multi_select; list_industries categories), company_investments_top_sdg (multi_select; list_industries sdg_categories), company_investments_top_country (multi_select; list_locations countries), company_investments_top_region (multi_select; list_locations regions), company_investments_top_stage (funding_type_multi_select; lighthouse_discovery_list_funding_types `value`, no_funding not applicable). " +
|
|
185
|
-
"Team: company_team_past_companies (work_experience_multi_select; ids from lighthouse_discovery_search_entities record_type 'company'), company_team_education (education_multi_select; ids from lighthouse_discovery_search_entities record_type 'school'),
|
|
240
|
+
"Team: company_team_past_companies (work_experience_multi_select; ids from lighthouse_discovery_search_entities record_type 'company'), company_team_education (education_multi_select; ids from lighthouse_discovery_search_entities record_type 'school'), company_team_tags (multi_select; same values as person_tags; matches a company whose current founders or executives carry the tags, and contains_all may be met by different members), company_team_language (language_select), company_team_language_proficiency (multi_select; use with company_team_language to require a proficiency level), company_team_name_origin (multi_select; list_locations countries), company_team_location_country, company_team_location_region, company_team_work_country, company_team_work_region, company_team_edu_country, company_team_edu_region (multi_select; list_locations). " +
|
|
186
241
|
"CRM (filter the global database by YOUR workspace CRM): company_internal_crm_status (checkbox; eq true = already in your CRM, false = not), company_crm_list_entries (multi_select; companies that are in specific CRM company lists, list ids from lighthouse_crm_list_lists), company_crm_deals_list_entries (multi_select; companies whose deals are in specific deal lists), company_crm_team_network (multi_user; companies whose team members are connected to specific workspace users, ids from lighthouse_workspace_list_users), company_crm_team_list_entries (multi_select; companies whose team members are in specific people lists), company_external_crm_status (checkbox; needs a connected external CRM), company_external_crm_lists (multi_select; needs a connected external CRM). " +
|
|
187
242
|
'NESTED-GROUP EXAMPLE for "headcount 10-50 AND (HQ in US or UK) AND raised Series A": ' +
|
|
188
243
|
'{"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":[]}]} ' +
|
|
@@ -191,8 +246,8 @@ export function createServer(apiKey) {
|
|
|
191
246
|
"Basics: person_name (short_text), person_created_at (date). " +
|
|
192
247
|
"Investments (the person AS an investor, e.g. an angel; the investments_top_* keys match the investor's TOP-RANKED preference only, e.g. their #1 country, not every investment they ever made): person_is_investor (checkbox; eq true = only investors, eq false = exclude investors), person_num_investments (number; total recorded investments), person_investments_top_category (multi_select; list_industries categories), person_investments_top_sdg (multi_select; list_industries sdg_categories), person_investments_top_country (multi_select; list_locations countries), person_investments_top_region (multi_select; list_locations regions), person_investments_top_stage (funding_type_multi_select; lighthouse_discovery_list_funding_types `value`, no_funding not applicable). " +
|
|
193
248
|
"Industry: person_current_categories, person_past_categories (multi_select; values from lighthouse_discovery_list_industries → categories), person_current_sdg_impact, person_past_sdg_impact (multi_select; list_industries → sdg_categories). " +
|
|
194
|
-
"Signals: person_signals (signal_select; value {\"signals\":[...],\"registration_countries\"?:[...],\"signal_dates\"?:{<signal>:\"YYYY-MM-DD\"},\"stealth_predicted_areas\"?:[...]}), person_registration_country (multi_select; filters company_registration signals by country,
|
|
195
|
-
"Tags: person_tags (multi_select; values: phd_doctorate, 2nd_time_founder, serial_founder, has_exit, raised_funding, sales_exp, co_exp, eng_exp, ai_and_data_exp, product_exp, hr_exp). " +
|
|
249
|
+
"Signals: person_signals (signal_select; value {\"signals\":[...],\"registration_countries\"?:[...],\"signal_dates\"?:{<signal>:\"YYYY-MM-DD\"},\"stealth_predicted_areas\"?:[...]}), person_registration_country (multi_select; filters company_registration signals by country, values from lighthouse_discovery_list_locations → registration_countries, the countries whose company registers we read; the country is where the company was registered, so any other country returns no people). " +
|
|
250
|
+
"Tags: person_tags (multi_select; values: phd_doctorate, master, bachelor, technical, 2nd_time_founder, serial_founder, has_exit, raised_funding, sales_exp, co_exp, eng_exp, ai_and_data_exp, product_exp, hr_exp). " +
|
|
196
251
|
"Language & origin: person_language (language_select), person_language_proficiency (multi_select; use with person_language to require a proficiency level), person_name_origin (multi_select; list_locations countries). " +
|
|
197
252
|
"Current location: person_country (multi_select; UPPERCASE ISO alpha-2 codes e.g. \"US\", \"GB\" from lighthouse_discovery_list_locations → countries), person_region (multi_select; list_locations → regions), person_german_state (multi_select; German federal states). " +
|
|
198
253
|
"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). " +
|
|
@@ -204,13 +259,13 @@ export function createServer(apiKey) {
|
|
|
204
259
|
"(an outer AND of two OR groups; country codes from list_locations).";
|
|
205
260
|
server.registerTool("lighthouse_crm_list_attributes", {
|
|
206
261
|
title: "List CRM Attributes",
|
|
207
|
-
description: "Get a lightweight catalog of the fields for a CRM record type: each field's permalink, label, data_type, links_to, read_only, is_custom, has_options, and
|
|
262
|
+
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. " +
|
|
208
263
|
"`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. " +
|
|
209
264
|
"ALWAYS call this before building filters for search_records / list_records or creating/updating views. Use the exact permalinks it returns. " +
|
|
210
265
|
"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. " +
|
|
211
266
|
"Supports built-in types (company, person, deal, task) and custom objects (use the object's permalink, e.g. 'lp', 'project'). " +
|
|
212
267
|
"When called without record_type, returns all types including custom objects as top-level keys. " +
|
|
213
|
-
"A custom field that belongs to a single list
|
|
268
|
+
"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.",
|
|
214
269
|
inputSchema: {
|
|
215
270
|
record_type: z
|
|
216
271
|
.string()
|
|
@@ -425,7 +480,8 @@ export function createServer(apiKey) {
|
|
|
425
480
|
"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). " +
|
|
426
481
|
"NEVER guess field keys, ALWAYS call lighthouse_crm_list_attributes first to get the exact field keys " +
|
|
427
482
|
"for the record_type, then use those keys in your filter conditions. " +
|
|
428
|
-
"The response's `meta` includes `total` (an integer match count)
|
|
483
|
+
"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. " +
|
|
484
|
+
DOTTED_PATH_NAMES_UNCHECKED + " " +
|
|
429
485
|
"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. " +
|
|
430
486
|
"NOT for discovering new companies/people, use lighthouse_discovery_* tools for that.",
|
|
431
487
|
inputSchema: {
|
|
@@ -471,7 +527,8 @@ export function createServer(apiKey) {
|
|
|
471
527
|
"Filter these fields by the signal VALUES (strings). Search results carry the same shape. " +
|
|
472
528
|
"Two more read-only keys on a company: investors ({ company: [{ database_id, crm_id, name, avatar_url, is_lead }], person: [{ database_id, crm_id, first_name, last_name, avatar_url, is_lead }] }: the global database's investors of the company, both keys always present; database_id = the investor's id in the database tools and the value the investors filter takes, crm_id = the workspace's CRM record linked to that investor or null when it is not in the CRM, is_lead = led one of its rounds) and headcount_growth ({ \"1m\", \"3m\", \"6m\", \"1y\" }: the headcount's percent change per window, null when unknown, plus value = the headcount on measured_on; null when the record has no linked profile or no data). " +
|
|
473
529
|
"Two read-only keys on a person: experience and education ([{ database_id, crm_id, name, avatar_url }]: the companies the linked global profile worked at and the schools it studied at, always present, [] without a linked profile; database_id = the id in the database tools and the value the experience / education filters take, crm_id = the workspace's CRM company linked to that company or school, null when it is not in the CRM). " +
|
|
474
|
-
"A third read-only key on every company, person, deal and custom object record: notes ({ count, last_note_at, notes: [ { id, title, type, created_at, created_by, tags, records, meeting } ] }: the notes linked to the record, the newest five with the author, the kind, the tags, the records they link to (the record itself included) and the meeting details of a meeting note; never the body, which lighthouse_crm_get_note reads; count is every note the caller may see). " +
|
|
530
|
+
"A third read-only key on every company, person, deal and custom object record: notes ({ count, last_note_at, notes: [ { id, title, type, created_at, created_by, tags, records, meeting } ] }: the notes linked to the record, the newest five with the author, the kind, the tags, the records they link to that you can view (the record itself included) and the meeting details of a meeting note; never the body, which lighthouse_crm_get_note reads; count is every note the caller may see). " +
|
|
531
|
+
"One more read-only key on a person: sequences ([{ id, name, status, end_reason, current_step, step_count, added_at, started_at, ended_at }]: the outbound sequences the person is in that the API key's user can open, runs still going first, then the most recently added; always present, [] when there are none, or when the user's role cannot view both sequences and people; id = the sequence's id, the value the sequences filter takes; status = the person's own status in that sequence (in_review, ready, active, paused, account_disconnected, completed, stopped, failed, bounced or unsubscribed), never the sequence's; end_reason = why the run ended, a short key such as finished, replied or unsubscribed, null while the run is going; current_step = { number (the step's place, from 1), type (email, linkedin_connection_request or linkedin_message) }, null before the first step or once that step no longer exists; step_count = how many steps the sequence has; added_at, started_at and ended_at are ISO 8601 timestamps, null when unset). " +
|
|
475
532
|
"A read-only duration twin sits beside every status field (time_in_stage on a deal, time_in_status on a task, time_in_<status field> beside a custom status field; lighthouse_crm_list_attributes lists each with its source_field): { days, since } (the whole days the record has held its current status and the ISO timestamp since when), null while the status is empty. " +
|
|
476
533
|
"(Discovery profiles keep their own signals list: one row per feed window with type, created_at, timeframe, percentage_change.)",
|
|
477
534
|
inputSchema: {
|
|
@@ -494,6 +551,7 @@ export function createServer(apiKey) {
|
|
|
494
551
|
"source always has { type, label }: type is app / mobile / api / mcp / workflow / ai_assistant / notetaker / email_sync / calendar_sync / linkedin_sync / inbound_email / inbound_whatsapp / lp_portal / sync, and label names the exact origin. The four trace keys are always present and null when the origin did not record them: workflow is { id, name, run_id, node_id } when a workflow made the change (tracing an entry back to the exact run and step), api_key_id / mcp_tool identify the key or AI tool behind an API change, and trigger is automatic or manual where the origin recorded whether a person asked for the change (meeting notes written on their own and notes a member asked for both log that member as the actor, so trigger is what tells them apart). " +
|
|
495
552
|
"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. " +
|
|
496
553
|
"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). " +
|
|
554
|
+
ACTIVITY_RELATED_RECORDS_LEFT_OUT + " " +
|
|
497
555
|
"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. " +
|
|
498
556
|
"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.",
|
|
499
557
|
inputSchema: {
|
|
@@ -518,7 +576,7 @@ export function createServer(apiKey) {
|
|
|
518
576
|
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. " +
|
|
519
577
|
"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. " +
|
|
520
578
|
"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. " +
|
|
521
|
-
"A report quoting a mailbox, calendar, LinkedIn account or file the API key's user cannot read is left out of the list (its author always sees it).",
|
|
579
|
+
"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).",
|
|
522
580
|
inputSchema: {
|
|
523
581
|
record_type: z.string().describe("Record type: company, person, deal, or a custom object permalink"),
|
|
524
582
|
record_id: z.string().uuid().optional().describe("Record ID whose reports to list"),
|
|
@@ -540,7 +598,7 @@ export function createServer(apiKey) {
|
|
|
540
598
|
title: "Get AI Report",
|
|
541
599
|
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). " +
|
|
542
600
|
"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. " +
|
|
543
|
-
"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, LinkedIn account or document the API key's user cannot read, and 404 for an unknown id.",
|
|
601
|
+
"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.",
|
|
544
602
|
inputSchema: {
|
|
545
603
|
id: z.string().uuid().describe("Report ID from lighthouse_crm_list_ai_reports"),
|
|
546
604
|
},
|
|
@@ -790,14 +848,17 @@ export function createServer(apiKey) {
|
|
|
790
848
|
server.registerTool("lighthouse_crm_list_lists", {
|
|
791
849
|
title: "List CRM Lists",
|
|
792
850
|
description: "Get all CRM lists in your workspace. Each list's record_type is 'company', 'person', 'deal', or a custom object permalink (e.g. 'lp', 'project'), and that is the record type of everything in it. " +
|
|
793
|
-
"Each row carries owner (the member who created it) and is_owner (true when that member is the API key's user), so 'my lists' and 'lists shared with me' can be told apart without a second call."
|
|
851
|
+
"Each row carries owner (the member who created it) and is_owner (true when that member is the API key's user), so 'my lists' and 'lists shared with me' can be told apart without a second call. " +
|
|
852
|
+
"WHEN TO USE: to find a list someone names (\"the Deals Reviewed list\"), pass search with the words of its name and read the id off the matching row, instead of paging through every list; leave search out to page through all of them. meta.total counts the lists that matched.",
|
|
794
853
|
inputSchema: {
|
|
854
|
+
search: z.string().optional().describe("Only lists whose name contains every one of these words, in any case. A list with a similar name is a different list."),
|
|
855
|
+
query: z.string().optional().describe("Same as search. Either name works; search wins when both are sent."),
|
|
795
856
|
limit: z.number().min(1).max(100).default(25),
|
|
796
857
|
offset: z.number().min(0).default(0),
|
|
797
858
|
},
|
|
798
859
|
}, async (params) => {
|
|
799
860
|
try {
|
|
800
|
-
const res = await api.
|
|
861
|
+
const res = await api.post("/lists/search", params);
|
|
801
862
|
return text(res);
|
|
802
863
|
}
|
|
803
864
|
catch (e) {
|
|
@@ -860,8 +921,9 @@ export function createServer(apiKey) {
|
|
|
860
921
|
server.registerTool("lighthouse_crm_list_list_records", {
|
|
861
922
|
title: "List Records in a List",
|
|
862
923
|
description: "Get records in a CRM list with optional filtering, sorting, and view support. " +
|
|
863
|
-
"Same filter format as
|
|
864
|
-
"The response's `meta` includes `total` (an integer match count), `ignored_filter_fields` (any filter key that is not a valid attribute for this list's record type, each with a did_you_mean suggestion when a close match can be computed) and `invalid_sort_fields` (a sort this list cannot apply, reported rather than refused, so the records come back in their default order). Check
|
|
924
|
+
"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. " +
|
|
925
|
+
"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. " +
|
|
926
|
+
DOTTED_PATH_NAMES_UNCHECKED + " " +
|
|
865
927
|
"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.",
|
|
866
928
|
inputSchema: {
|
|
867
929
|
list_id: z.string().uuid().optional().describe("List ID"),
|
|
@@ -1014,9 +1076,10 @@ export function createServer(apiKey) {
|
|
|
1014
1076
|
});
|
|
1015
1077
|
server.registerTool("lighthouse_crm_list_notes", {
|
|
1016
1078
|
title: "List Notes",
|
|
1017
|
-
description: "Get notes for a CRM record. Each note in the response includes a `records` map, every record (company/person/deal/custom object) the note is currently linked to, plus a `tags` array of any workspace tags attached to the note (each with id, label, color). " +
|
|
1079
|
+
description: "Get notes for a CRM record. Each note in the response includes a `records` map, every record (company/person/deal/custom object) the note is currently linked to that you can view, plus a `tags` array of any workspace tags attached to the note (each with id, label, color). " +
|
|
1080
|
+
LINKED_RECORDS_LEFT_OUT + " " +
|
|
1018
1081
|
"`tagged_users` is the teammates the note MENTIONS, each the member object this API answers for a person ({ id, first_name, last_name, full_name, avatar_url, email }), and [] when it mentions nobody. It is read from the mention spans in `content`, not from the ids a write sent, so a note written with tagged_users ids and no mention span reads back []: always write both, as lighthouse_crm_create_note and lighthouse_crm_update_note say. " +
|
|
1019
|
-
"Every note also carries `type` ('standard' for a note a member wrote, 'meeting' for meeting notes written from a recorded meeting) and `meeting` (null on a standard note; on a meeting note { title, occurred_at, duration_seconds, call_type, ical_uid, attendees, has_transcript, has_recording }, where attendees is [{ email, name }] with name possibly null, call_type is a short category from a fixed list such as 'First founder call' or 'Internal discussion',
|
|
1082
|
+
"Every note also carries `type` ('standard' for a note a member wrote, 'meeting' for meeting notes written from a recorded meeting) and `meeting` (null on a standard note; on a meeting note { meeting_key, title, occurred_at, duration_seconds, call_type, ical_uid, attendees, has_transcript, has_recording }, where attendees is [{ email, name }] with name possibly null, call_type is a short category from a fixed list such as 'First founder call' or 'Internal discussion', duration_seconds and ical_uid are null when unknown and attendees is [] when there are none). " + NOTE_MEETING_KEY_RULE + " Meeting notes are included only when the API key's user may read the meeting. " + MEETING_ACCESS_RULE,
|
|
1020
1083
|
inputSchema: {
|
|
1021
1084
|
record_id: z.string().uuid().describe("Record ID to list notes for"),
|
|
1022
1085
|
limit: z.number().min(1).max(100).default(25),
|
|
@@ -1043,7 +1106,7 @@ export function createServer(apiKey) {
|
|
|
1043
1106
|
"type (select: contains_any / not_contains_any ONLY, with an array of \"standard\" and / or \"meeting\"; [\"meeting\"] selects meeting notes written from a recorded meeting, [\"standard\"] the notes members wrote; any other value is rejected). " +
|
|
1044
1107
|
"An unknown field or invalid operator is rejected with a clear error naming it. " +
|
|
1045
1108
|
"`tagged_users` is the teammates the note MENTIONS, each the member object this API answers for a person ({ id, first_name, last_name, full_name, avatar_url, email }), and [] when it mentions nobody. It is read from the mention spans in `content`, not from the ids a write sent, so a note written with tagged_users ids and no mention span reads back []: always write both, as lighthouse_crm_create_note and lighthouse_crm_update_note say. " +
|
|
1046
|
-
"Every returned note carries `type` ('standard' | 'meeting') and `meeting` (null on a standard note; on a meeting note { title, occurred_at, duration_seconds, call_type, ical_uid, attendees, has_transcript, has_recording }; attendees is [{ email, name }] with name possibly null; duration_seconds
|
|
1109
|
+
"Every returned note carries `type` ('standard' | 'meeting') and `meeting` (null on a standard note; on a meeting note { meeting_key, title, occurred_at, duration_seconds, call_type, ical_uid, attendees, has_transcript, has_recording }; attendees is [{ email, name }] with name possibly null; duration_seconds and ical_uid are null when unknown and attendees is [] when there are none). " + NOTE_MEETING_KEY_RULE + " Meeting notes are included only when the API key's user may read the meeting. " + MEETING_ACCESS_RULE,
|
|
1047
1110
|
inputSchema: {
|
|
1048
1111
|
search: z.string().optional().describe("Free-text search on the note title"),
|
|
1049
1112
|
filters: z.record(z.any()).optional().describe("CRM-style filter object for notes (see tool description for fields + operators)"),
|
|
@@ -1078,7 +1141,7 @@ export function createServer(apiKey) {
|
|
|
1078
1141
|
records: z
|
|
1079
1142
|
.record(z.array(z.string().uuid()))
|
|
1080
1143
|
.optional()
|
|
1081
|
-
.describe("Records to link, keyed by record type. Optional: omit for a standalone note. Any record ids you pass must exist or the call
|
|
1144
|
+
.describe("Records to link, keyed by record type. Optional: omit for a standalone note. Any record ids you pass must exist, or the call answers 404 not_found. " + NOTE_CREATE_LINK_RULE),
|
|
1082
1145
|
title: z.string().describe("Note title"),
|
|
1083
1146
|
content: z.string().describe("Note content (plain text or HTML). Use <p> tags for paragraphs. For @mentions, include mention spans (see description)."),
|
|
1084
1147
|
tagged_users: z
|
|
@@ -1101,9 +1164,10 @@ export function createServer(apiKey) {
|
|
|
1101
1164
|
});
|
|
1102
1165
|
server.registerTool("lighthouse_crm_get_note", {
|
|
1103
1166
|
title: "Get Note",
|
|
1104
|
-
description: "Get a single note by ID. Response includes a `records` object keyed by record type (or custom object permalink) with every linked record, plus a `tags` array of any workspace tags attached to the note (each with permalink, label, color). " +
|
|
1167
|
+
description: "Get a single note by ID. Response includes a `records` object keyed by record type (or custom object permalink) with every linked record you can view, plus a `tags` array of any workspace tags attached to the note (each with permalink, label, color). " +
|
|
1168
|
+
LINKED_RECORDS_LEFT_OUT + " " +
|
|
1105
1169
|
"`tagged_users` is the teammates the note MENTIONS, each the member object this API answers for a person ({ id, first_name, last_name, full_name, avatar_url, email }), and [] when it mentions nobody. It is read from the mention spans in `content`, not from the ids a write sent, so a note written with tagged_users ids and no mention span reads back []: always write both, as lighthouse_crm_create_note and lighthouse_crm_update_note say. " +
|
|
1106
|
-
"`type` is 'standard' for a note a member wrote and 'meeting' for meeting notes written from a recorded meeting; `meeting` is null on a standard note and { title, occurred_at, duration_seconds, call_type, ical_uid, attendees, has_transcript, has_recording } on a meeting note: title and occurred_at come from the meeting itself, attendees is [{ email, name }] with name possibly null, call_type is one of 'First founder call', 'Follow-up founder call', 'Diligence call', 'Re-engagement', 'Portfolio check-in', 'Board meeting', 'Investment committee', 'LP meeting', 'Fundraising call', 'Co-investor or syndication call', 'Internal discussion', 'Hiring or reference call', 'Partnership or vendor call' or 'Other',
|
|
1170
|
+
"`type` is 'standard' for a note a member wrote and 'meeting' for meeting notes written from a recorded meeting; `meeting` is null on a standard note and { meeting_key, title, occurred_at, duration_seconds, call_type, ical_uid, attendees, has_transcript, has_recording } on a meeting note: title and occurred_at come from the meeting itself, attendees is [{ email, name }] with name possibly null, call_type is one of 'First founder call', 'Follow-up founder call', 'Diligence call', 'Re-engagement', 'Portfolio check-in', 'Board meeting', 'Investment committee', 'LP meeting', 'Fundraising call', 'Co-investor or syndication call', 'Internal discussion', 'Hiring or reference call', 'Partnership or vendor call' or 'Other', duration_seconds and ical_uid are null when unknown, and attendees is [] when there are none. " + NOTE_MEETING_KEY_RULE + " A meeting note is returned only when the API key's user may read the meeting; otherwise it is reported as not found. " + MEETING_ACCESS_RULE,
|
|
1107
1171
|
inputSchema: {
|
|
1108
1172
|
note_id: z.string().uuid().optional().describe("Note ID"),
|
|
1109
1173
|
id: z.string().uuid().optional().describe("Same as note_id, the name this tool has always taken. Send either one."),
|
|
@@ -1165,7 +1229,8 @@ export function createServer(apiKey) {
|
|
|
1165
1229
|
title: "Link Record to Note",
|
|
1166
1230
|
description: "Attach a record to an existing note. Call once per record. " +
|
|
1167
1231
|
"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. " +
|
|
1168
|
-
|
|
1232
|
+
LINK_NOT_VIEWABLE_RULE + " " +
|
|
1233
|
+
"Use lighthouse_crm_get_note to see the current set of linked records (the ones you can view).",
|
|
1169
1234
|
inputSchema: {
|
|
1170
1235
|
note_id: z.string().uuid().describe("Note ID"),
|
|
1171
1236
|
record_type: z.string().describe("Record type: company, person, deal, or a custom object permalink"),
|
|
@@ -1183,7 +1248,8 @@ export function createServer(apiKey) {
|
|
|
1183
1248
|
server.registerTool("lighthouse_crm_unlink_note_record", {
|
|
1184
1249
|
title: "Unlink Record from Note",
|
|
1185
1250
|
description: "Remove a record link from a note. Call once per record. " +
|
|
1186
|
-
"Removing the last linked record is allowed and leaves a standalone note (it stays in the workspace Notes section)."
|
|
1251
|
+
"Removing the last linked record is allowed and leaves a standalone note (it stays in the workspace Notes section). " +
|
|
1252
|
+
UNLINK_NOT_VIEWABLE_KEPT,
|
|
1187
1253
|
inputSchema: {
|
|
1188
1254
|
note_id: z.string().uuid().describe("Note ID"),
|
|
1189
1255
|
record_type: z.string().describe("Record type: company, person, deal, or a custom object permalink"),
|
|
@@ -1345,7 +1411,7 @@ export function createServer(apiKey) {
|
|
|
1345
1411
|
records: z
|
|
1346
1412
|
.record(z.array(z.string().uuid()))
|
|
1347
1413
|
.optional()
|
|
1348
|
-
.describe("Records to link, keyed by type: {company: [ids], person: [ids], deal: [ids]}; a custom object's records go under its permalink (e.g. lp: [ids])"),
|
|
1414
|
+
.describe("Records to link, keyed by type: {company: [ids], person: [ids], deal: [ids]}; a custom object's records go under its permalink (e.g. lp: [ids]). " + LINK_NOT_VIEWABLE_RULE),
|
|
1349
1415
|
},
|
|
1350
1416
|
}, async (params) => {
|
|
1351
1417
|
try {
|
|
@@ -1359,7 +1425,7 @@ export function createServer(apiKey) {
|
|
|
1359
1425
|
server.registerTool("lighthouse_crm_update_task", {
|
|
1360
1426
|
title: "Update Task",
|
|
1361
1427
|
description: "Update a CRM task. Provide only fields you want to change. " +
|
|
1362
|
-
"assigned_to replaces all current assignees. records
|
|
1428
|
+
"assigned_to replaces all current assignees. In records, " + TASK_LINKS_PER_TYPE.charAt(0).toLowerCase() + TASK_LINKS_PER_TYPE.slice(1) + " " +
|
|
1363
1429
|
"Status values are configured per workspace and are NOT fixed: call lighthouse_crm_list_options with record_type 'task' and permalink 'status' to get the allowed values, then pass the option's value (not its label) in `status`. " +
|
|
1364
1430
|
"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.",
|
|
1365
1431
|
inputSchema: {
|
|
@@ -1376,7 +1442,7 @@ export function createServer(apiKey) {
|
|
|
1376
1442
|
records: z
|
|
1377
1443
|
.record(z.array(z.string().uuid()))
|
|
1378
1444
|
.optional()
|
|
1379
|
-
.describe("Records to link
|
|
1445
|
+
.describe("Records to link, keyed by type: {company: [ids], person: [ids], deal: [ids]}; a custom object's records go under its permalink (e.g. lp: [ids]). " + TASK_LINKS_PER_TYPE + " " + LINK_NOT_VIEWABLE_RULE + " " + TASK_UNVIEWABLE_LINKS_KEPT),
|
|
1380
1446
|
},
|
|
1381
1447
|
}, async (params) => {
|
|
1382
1448
|
try {
|
|
@@ -1575,7 +1641,7 @@ export function createServer(apiKey) {
|
|
|
1575
1641
|
});
|
|
1576
1642
|
server.registerTool("lighthouse_documents_get_file", {
|
|
1577
1643
|
title: "Get File",
|
|
1578
|
-
description: "Get file metadata. Response includes my_role ('owner' | 'editor' | 'viewer'), is_owner, owner (the member who owns it: { id, first_name, last_name, full_name, avatar_url, email }), sharing ('private' or 'workspace'), linked_records_count, linked_records[] and shared_with_teams[] (each entry has { team_id, team_name, role, granted_at }). Pass download_url=true to also receive a short-lived signed read URL (expires in 15 minutes).",
|
|
1644
|
+
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.",
|
|
1579
1645
|
inputSchema: {
|
|
1580
1646
|
file_id: z.string().uuid().describe("File UUID"),
|
|
1581
1647
|
download_url: z.boolean().optional().describe("Include a short-lived signed download URL"),
|
|
@@ -1590,6 +1656,39 @@ export function createServer(apiKey) {
|
|
|
1590
1656
|
return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
1591
1657
|
}
|
|
1592
1658
|
});
|
|
1659
|
+
server.registerTool("lighthouse_documents_get_file_content", {
|
|
1660
|
+
title: "Read File",
|
|
1661
|
+
description: "What one stored file SAYS, as text: PDFs, Word, Excel and PowerPoint files (and their OpenDocument versions), text, CSV and HTML. " +
|
|
1662
|
+
"WHEN TO USE: the answer is inside a document the workspace keeps (\"what are the conditions in the term sheet\", \"summarise the memo on this deal\", \"what does the deck say about revenue\"). " +
|
|
1663
|
+
"Take the file id from lighthouse_documents_list or lighthouse_documents_list_for_record. " +
|
|
1664
|
+
"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. " +
|
|
1665
|
+
FILE_CONTENT_RULES +
|
|
1666
|
+
" A file the user may not open answers forbidden, one that does not exist not found.",
|
|
1667
|
+
inputSchema: {
|
|
1668
|
+
file_id: z.string().uuid().optional().describe("File UUID. The name every other file tool takes."),
|
|
1669
|
+
id: z.string().uuid().optional().describe("Same as file_id. Send either one."),
|
|
1670
|
+
offset_chars: z.number().min(0).default(0).describe("Character offset to start from (meta.next_offset_chars of the previous window)"),
|
|
1671
|
+
max_chars: z.number().min(1).default(FILE_CONTENT_WINDOW_CHARS).describe("Characters per window"),
|
|
1672
|
+
},
|
|
1673
|
+
}, async (params) => {
|
|
1674
|
+
try {
|
|
1675
|
+
const fileId = requireId(params.file_id, params.id, "file_id");
|
|
1676
|
+
const res = await api.get(`/v1/documents/files/${fileId}/content`, {
|
|
1677
|
+
offset_chars: params.offset_chars,
|
|
1678
|
+
max_chars: params.max_chars,
|
|
1679
|
+
});
|
|
1680
|
+
const imageType = viewableImageType(res, (data) => ({ contentType: data.content_type, size: data.size_bytes }));
|
|
1681
|
+
if (!imageType)
|
|
1682
|
+
return text(res);
|
|
1683
|
+
const signed = await api.get(`/v1/documents/files/${fileId}`, { download_url: "1" });
|
|
1684
|
+
const url = signed?.data?.download_url;
|
|
1685
|
+
const image = typeof url === "string" ? await api.downloadSigned(url, IMAGE_BYTES_MAX) : null;
|
|
1686
|
+
return textWithImage(res, image, imageType);
|
|
1687
|
+
}
|
|
1688
|
+
catch (e) {
|
|
1689
|
+
return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
1690
|
+
}
|
|
1691
|
+
});
|
|
1593
1692
|
server.registerTool("lighthouse_documents_update_file", {
|
|
1594
1693
|
title: "Update File",
|
|
1595
1694
|
description: "Rename, move, or change sharing on a file. Editors can rename/move-between-folders and delete; only the owner can move to root, change sharing, or grant team access. " +
|
|
@@ -1721,14 +1820,15 @@ export function createServer(apiKey) {
|
|
|
1721
1820
|
"To change sharing settings, use lighthouse_crm_update_view_sharing instead. " +
|
|
1722
1821
|
"Uses CRM filter format, call lighthouse_crm_list_attributes first if modifying filters. " +
|
|
1723
1822
|
"A semantic_search condition in the new filters must carry its vector (keep the one the view already has, from lighthouse_crm_get_view, or one copied from the app): this API never builds an embedding from text. " +
|
|
1724
|
-
"Every key is optional and a key you leave out keeps the value the view already has, the board settings included, so changing a filter never reopens the columns someone collapsed."
|
|
1823
|
+
"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. " +
|
|
1824
|
+
SAVED_FILTER_REFUSAL + " " + DOTTED_PATH_NAMES_UNCHECKED,
|
|
1725
1825
|
inputSchema: {
|
|
1726
1826
|
id: z.string().uuid().describe("View ID"),
|
|
1727
1827
|
name: z.string().optional(),
|
|
1728
1828
|
filters: z.record(z.unknown()).optional().describe(CRM_FILTER_DESCRIPTION),
|
|
1729
1829
|
sort: z.array(z.record(z.unknown())).optional().describe(CRM_SORT_DESCRIPTION),
|
|
1730
1830
|
view_type: z.enum(["table", "kanban"]).optional(),
|
|
1731
|
-
grouped_by: z.string().optional().describe(
|
|
1831
|
+
grouped_by: z.string().optional().describe(BOARD_GROUPING_RULE),
|
|
1732
1832
|
hidden_kanban_groups: z
|
|
1733
1833
|
.array(z.unknown())
|
|
1734
1834
|
.nullable()
|
|
@@ -1860,6 +1960,47 @@ export function createServer(apiKey) {
|
|
|
1860
1960
|
return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
1861
1961
|
}
|
|
1862
1962
|
});
|
|
1963
|
+
const WEBHOOK_SUBSCRIPTION_SHAPE = "Each subscription: id, url, record_type, events ('record.created' and/or 'record.updated'), header_names (the names of the custom headers sent with every delivery, never their values), description, " +
|
|
1964
|
+
"status ('active' or 'disabled'), disabled_reason (null, 'endpoint_gone' when the address answered 410 Gone, 'failing' after many failed tries in a row, 'access_lost' when the owner lost developer access or access to the record type), disabled_at, " +
|
|
1965
|
+
"last_delivery (null before the first try, else { attempted_at, status: 'delivered' or 'failed', response_status }), owner (the member it belongs to), created_at and updated_at. The signing secret is never returned.";
|
|
1966
|
+
const WEBHOOK_SUBSCRIPTION_WRITES = "This tool only reads: creating and deleting webhook subscriptions is done with the REST API (POST /v1/webhook-subscriptions and DELETE /v1/webhook-subscriptions/{id}) or the SDKs, with an API key, and a subscription can also be deleted in the app, under Settings > Developer > Webhook Subscriptions. A turned-off subscription is not turned back on: delete it and create a new one.";
|
|
1967
|
+
server.registerTool("lighthouse_workspace_list_webhook_subscriptions", {
|
|
1968
|
+
title: "List Webhook Subscriptions",
|
|
1969
|
+
description: "List the webhook subscriptions of the user this key acts as (only their own), newest first, active and turned off alike: each one sends a signed POST to its url whenever a record of its record_type is created or updated. " +
|
|
1970
|
+
WEBHOOK_SUBSCRIPTION_SHAPE + " " +
|
|
1971
|
+
"WHEN TO USE: \"which webhooks do I have\", \"where are our company updates being sent\", \"why did our integration stop receiving changes\" (read status, disabled_reason and last_delivery). " +
|
|
1972
|
+
"Needs a role with developer access. " + WEBHOOK_SUBSCRIPTION_WRITES,
|
|
1973
|
+
inputSchema: {
|
|
1974
|
+
limit: z.number().min(1).max(100).default(25),
|
|
1975
|
+
offset: z.number().min(0).default(0),
|
|
1976
|
+
},
|
|
1977
|
+
}, async (params) => {
|
|
1978
|
+
try {
|
|
1979
|
+
const res = await api.get("/webhook-subscriptions", params);
|
|
1980
|
+
return text(res);
|
|
1981
|
+
}
|
|
1982
|
+
catch (e) {
|
|
1983
|
+
return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
1984
|
+
}
|
|
1985
|
+
});
|
|
1986
|
+
server.registerTool("lighthouse_workspace_get_webhook_subscription", {
|
|
1987
|
+
title: "Get Webhook Subscription",
|
|
1988
|
+
description: "Get one of the webhook subscriptions of the user this key acts as, by id. " +
|
|
1989
|
+
WEBHOOK_SUBSCRIPTION_SHAPE + " " +
|
|
1990
|
+
"WHEN TO USE: \"is that webhook still on\", \"when did it last deliver\", for one subscription whose id you already have (from lighthouse_workspace_list_webhook_subscriptions). " +
|
|
1991
|
+
"Another member's subscription is not found, the same as an id that does not exist. Needs a role with developer access. " + WEBHOOK_SUBSCRIPTION_WRITES,
|
|
1992
|
+
inputSchema: {
|
|
1993
|
+
id: z.string().uuid().describe("Webhook subscription ID"),
|
|
1994
|
+
},
|
|
1995
|
+
}, async (params) => {
|
|
1996
|
+
try {
|
|
1997
|
+
const res = await api.get(`/webhook-subscriptions/${params.id}`);
|
|
1998
|
+
return text(res);
|
|
1999
|
+
}
|
|
2000
|
+
catch (e) {
|
|
2001
|
+
return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
2002
|
+
}
|
|
2003
|
+
});
|
|
1863
2004
|
server.registerTool("lighthouse_discovery_list_attributes", {
|
|
1864
2005
|
title: "List Discovery Data Fields",
|
|
1865
2006
|
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. " +
|
|
@@ -1919,7 +2060,7 @@ export function createServer(apiKey) {
|
|
|
1919
2060
|
"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. " +
|
|
1920
2061
|
"4) For investor/past-company/education filters, call lighthouse_discovery_search_entities (record_type 'investor', 'company', or 'school') to resolve names to ids. " +
|
|
1921
2062
|
"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). " +
|
|
1922
|
-
"The response's `meta` includes `total` (the match count, e.g. \"247\", or \"1k+\" when there are 1000+) and `ignored_filter_fields` (any filter key that was NOT
|
|
2063
|
+
"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." +
|
|
1923
2064
|
DISCOVERY_CRM_ID_NOTE,
|
|
1924
2065
|
inputSchema: {
|
|
1925
2066
|
filters: z
|
|
@@ -1949,7 +2090,7 @@ export function createServer(apiKey) {
|
|
|
1949
2090
|
"3) For location filters (person_country, person_region, and any other *_country/*_region key), call lighthouse_discovery_list_locations to get country/region codes. " +
|
|
1950
2091
|
"4) For past-company/education filters, call lighthouse_discovery_search_entities (record_type 'company' or 'school') to resolve names to ids. " +
|
|
1951
2092
|
"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. " +
|
|
1952
|
-
"The response's `meta` includes `total` (the match count, e.g. \"247\", or \"1k+\" when there are 1000+) and `ignored_filter_fields` (any filter key that was NOT
|
|
2093
|
+
"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. " +
|
|
1953
2094
|
"People results are returned by founder relevance; custom sorting is NOT supported for people, so there is no sort parameter." +
|
|
1954
2095
|
DISCOVERY_CRM_ID_NOTE,
|
|
1955
2096
|
inputSchema: {
|
|
@@ -1976,7 +2117,7 @@ export function createServer(apiKey) {
|
|
|
1976
2117
|
"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. " +
|
|
1977
2118
|
"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. " +
|
|
1978
2119
|
"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. " +
|
|
1979
|
-
"The response's `meta` includes `total` (the match count, e.g. \"247\", or \"1k+\" when there are 1000+) and `ignored_filter_fields` (any filter key that was NOT
|
|
2120
|
+
"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. " +
|
|
1980
2121
|
"`query` is required; everything else is optional." +
|
|
1981
2122
|
DISCOVERY_CRM_ID_NOTE,
|
|
1982
2123
|
inputSchema: {
|
|
@@ -2010,7 +2151,7 @@ export function createServer(apiKey) {
|
|
|
2010
2151
|
"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. " +
|
|
2011
2152
|
"PREFER lighthouse_discovery_search_people (no `query`) when the user only needs exact attribute filters with no conceptual component. " +
|
|
2012
2153
|
"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. " +
|
|
2013
|
-
"The response's `meta` includes `total` (the match count, e.g. \"247\", or \"1k+\" when there are 1000+) and `ignored_filter_fields` (any filter key that was NOT
|
|
2154
|
+
"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. " +
|
|
2014
2155
|
"`query` is required; everything else is optional." +
|
|
2015
2156
|
DISCOVERY_CRM_ID_NOTE,
|
|
2016
2157
|
inputSchema: {
|
|
@@ -2150,9 +2291,11 @@ export function createServer(apiKey) {
|
|
|
2150
2291
|
description: "Get available location options (countries and regions) for discovery filters. " +
|
|
2151
2292
|
"ALWAYS call this to get valid country/region values before filtering by location fields " +
|
|
2152
2293
|
"(company_hq_country, company_hq_region, person_country, person_region, company_investor_country, etc.). " +
|
|
2153
|
-
"Returns {countries: [{name, value, flag}], regions: [{name, value, flag}]}. " +
|
|
2294
|
+
"Returns {countries: [{name, value, flag}], regions: [{name, value, flag}], registration_countries: [{name, value, flag}]}. " +
|
|
2154
2295
|
"Use 'value' (alpha-2 codes for countries e.g. 'DE' for Germany, IDs for regions) " +
|
|
2155
|
-
"in filter conditions with contains_any/not_contains_any operators."
|
|
2296
|
+
"in filter conditions with contains_any/not_contains_any operators. " +
|
|
2297
|
+
"registration_countries are the countries whose company registers we read: the only values that can match person_registration_country " +
|
|
2298
|
+
"and the registration_countries of the company_registration person signal, so any other country returns no people.",
|
|
2156
2299
|
inputSchema: {},
|
|
2157
2300
|
}, async () => {
|
|
2158
2301
|
try {
|
|
@@ -2520,8 +2663,7 @@ export function createServer(apiKey) {
|
|
|
2520
2663
|
title: "Update Dashboard",
|
|
2521
2664
|
description: "Update a dashboard's name, description, or sharing. Only the dashboard owner can update. " +
|
|
2522
2665
|
"Returns the full dashboard object (same format as get dashboard), plus shared_along and audience_cannot_open. " +
|
|
2523
|
-
|
|
2524
|
-
"the user's role can share lists. The response always carries shared_along and audience_cannot_open.",
|
|
2666
|
+
SHARE_ALONG_RULE,
|
|
2525
2667
|
inputSchema: {
|
|
2526
2668
|
id: z.string().uuid().describe("Dashboard ID"),
|
|
2527
2669
|
name: z.string().optional().describe("New dashboard name"),
|
|
@@ -2530,7 +2672,7 @@ export function createServer(apiKey) {
|
|
|
2530
2672
|
share_along: z
|
|
2531
2673
|
.boolean()
|
|
2532
2674
|
.optional()
|
|
2533
|
-
.describe("
|
|
2675
|
+
.describe(shareAlongInput("this dashboard's widgets filter on")),
|
|
2534
2676
|
},
|
|
2535
2677
|
}, async (params) => {
|
|
2536
2678
|
try {
|
|
@@ -2581,6 +2723,8 @@ export function createServer(apiKey) {
|
|
|
2581
2723
|
"2) Build a config object using those keys, 3) Create the widget. " +
|
|
2582
2724
|
"Widget types: bar, line, pie, table, kpi, funnel, area. " +
|
|
2583
2725
|
"Report types: standard (aggregate by field), funnel (pipeline conversion), stage_changes (historical transitions). " +
|
|
2726
|
+
"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. " +
|
|
2727
|
+
SAVED_FILTER_REFUSAL + " " + DOTTED_PATH_NAMES_UNCHECKED + " " +
|
|
2584
2728
|
"Returns: {id, name, widget_type, config, created_at, updated_at}.",
|
|
2585
2729
|
inputSchema: {
|
|
2586
2730
|
dashboard_id: z.string().uuid().describe("Dashboard ID to add widget to"),
|
|
@@ -2663,15 +2807,14 @@ export function createServer(apiKey) {
|
|
|
2663
2807
|
description: "Grant team-level access to a dashboard. Only the dashboard owner can share. " +
|
|
2664
2808
|
"Use lighthouse_workspace_list_teams to find team IDs first. " +
|
|
2665
2809
|
"Requires reports.edit permission. " +
|
|
2666
|
-
|
|
2667
|
-
"the user's role can share lists. The response always carries shared_along and audience_cannot_open.",
|
|
2810
|
+
SHARE_ALONG_RULE,
|
|
2668
2811
|
inputSchema: {
|
|
2669
2812
|
id: z.string().uuid().describe("Dashboard ID"),
|
|
2670
2813
|
team_ids: z.array(z.string().uuid()).describe("Array of team UUIDs to grant access to"),
|
|
2671
2814
|
share_along: z
|
|
2672
2815
|
.boolean()
|
|
2673
2816
|
.optional()
|
|
2674
|
-
.describe("
|
|
2817
|
+
.describe(shareAlongInput("this dashboard's widgets filter on")),
|
|
2675
2818
|
},
|
|
2676
2819
|
}, async (params) => {
|
|
2677
2820
|
try {
|
|
@@ -2707,15 +2850,14 @@ export function createServer(apiKey) {
|
|
|
2707
2850
|
"Use lighthouse_workspace_list_teams to find team IDs first. " +
|
|
2708
2851
|
"Requires views.share permission. " +
|
|
2709
2852
|
"Returns formatted view: {id, name, record_type, list_id, view_type, filters, sort, grouped_by, sharing, owner, is_owner, shared_with_teams, created_at, shared_along, audience_cannot_open}, where is_owner says whether the API key's user owns the view. " +
|
|
2710
|
-
|
|
2711
|
-
"the user's role can share lists. The response always carries shared_along and audience_cannot_open.",
|
|
2853
|
+
SHARE_ALONG_RULE,
|
|
2712
2854
|
inputSchema: {
|
|
2713
2855
|
id: z.string().uuid().describe("View ID"),
|
|
2714
2856
|
team_ids: z.array(z.string().uuid()).describe("Array of team UUIDs to grant access to"),
|
|
2715
2857
|
share_along: z
|
|
2716
2858
|
.boolean()
|
|
2717
2859
|
.optional()
|
|
2718
|
-
.describe("
|
|
2860
|
+
.describe(shareAlongInput("this view filters on")),
|
|
2719
2861
|
},
|
|
2720
2862
|
}, async (params) => {
|
|
2721
2863
|
try {
|
|
@@ -2751,15 +2893,14 @@ export function createServer(apiKey) {
|
|
|
2751
2893
|
description: "Change a CRM view or list view's sharing visibility. Setting to 'private' also removes all team permissions. " +
|
|
2752
2894
|
"Automatically detects view type. Only the view owner can change sharing. Requires views.share permission. " +
|
|
2753
2895
|
"Returns formatted view: {id, name, record_type, list_id, view_type, filters, sort, grouped_by, sharing, owner, is_owner, shared_with_teams, created_at, shared_along, audience_cannot_open}, where is_owner says whether the API key's user owns the view. " +
|
|
2754
|
-
|
|
2755
|
-
"the user's role can share lists. The response always carries shared_along and audience_cannot_open.",
|
|
2896
|
+
SHARE_ALONG_RULE,
|
|
2756
2897
|
inputSchema: {
|
|
2757
2898
|
id: z.string().uuid().describe("View ID"),
|
|
2758
2899
|
sharing: z.enum(["private", "workspace"]).describe("New sharing visibility"),
|
|
2759
2900
|
share_along: z
|
|
2760
2901
|
.boolean()
|
|
2761
2902
|
.optional()
|
|
2762
|
-
.describe("
|
|
2903
|
+
.describe(shareAlongInput("this view filters on")),
|
|
2763
2904
|
},
|
|
2764
2905
|
}, async (params) => {
|
|
2765
2906
|
try {
|
|
@@ -2868,7 +3009,7 @@ export function createServer(apiKey) {
|
|
|
2868
3009
|
"body (the full HTML, null when there is none), reply_to_message_id (the message this one answers, when the mailbox states it), " +
|
|
2869
3010
|
"is_read, is_starred, attachments [{ id, message_id, type (image | video | audio | file | other), filename, content_type, size }], " +
|
|
2870
3011
|
"inline_images (the files embedded in the body: the same keys plus cid), account { id, name, email, is_owner } }. Every key is always present. " +
|
|
2871
|
-
"Read text when you need what was said and body only when the markup matters. " +
|
|
3012
|
+
"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. " +
|
|
2872
3013
|
"When the user can see only the metadata of that account, text and body are null, both file lists are empty and data.has_limited_access is true (false otherwise). Thread ids are " +
|
|
2873
3014
|
"account-specific: BOTH thread_id AND account_id (the thread row's account.id from " +
|
|
2874
3015
|
"lighthouse_inbox_email_threads) are required. " +
|
|
@@ -2899,6 +3040,38 @@ export function createServer(apiKey) {
|
|
|
2899
3040
|
return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
2900
3041
|
}
|
|
2901
3042
|
});
|
|
3043
|
+
server.registerTool("lighthouse_inbox_email_attachment", {
|
|
3044
|
+
title: "Read Email Attachment",
|
|
3045
|
+
description: "What one email attachment SAYS, as text: PDFs, Word, Excel and PowerPoint files (and their OpenDocument versions), text, CSV and HTML. " +
|
|
3046
|
+
"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\"). " +
|
|
3047
|
+
"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. " +
|
|
3048
|
+
"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 }. " +
|
|
3049
|
+
FILE_CONTENT_RULES +
|
|
3050
|
+
" An attachment in an account the user may see only the metadata of answers not found.",
|
|
3051
|
+
inputSchema: {
|
|
3052
|
+
message_id: z.string().describe("The message_id on the attachment (the message that carries it)"),
|
|
3053
|
+
attachment_id: z.string().describe("The attachment's id, from a message's attachments (or inline_images) in lighthouse_inbox_email_thread"),
|
|
3054
|
+
account_id: z.string().uuid().describe("Required: the account.id of that message (attachment ids belong to one account)"),
|
|
3055
|
+
offset_chars: z.number().min(0).default(0).describe("Character offset to start from (meta.next_offset_chars of the previous window)"),
|
|
3056
|
+
max_chars: z.number().min(1).default(FILE_CONTENT_WINDOW_CHARS).describe("Characters per window"),
|
|
3057
|
+
},
|
|
3058
|
+
}, async ({ message_id, attachment_id, account_id, offset_chars, max_chars }) => {
|
|
3059
|
+
try {
|
|
3060
|
+
const path = `/inbox/email/messages/${encodeURIComponent(message_id)}/attachments/${encodeURIComponent(attachment_id)}`;
|
|
3061
|
+
const res = await api.get(`${path}/content`, { account_id, offset_chars, max_chars });
|
|
3062
|
+
const imageType = viewableImageType(res, (data) => {
|
|
3063
|
+
const attachment = (data.attachment ?? {});
|
|
3064
|
+
return { contentType: attachment.content_type, size: attachment.size };
|
|
3065
|
+
});
|
|
3066
|
+
if (!imageType)
|
|
3067
|
+
return text(res);
|
|
3068
|
+
const image = await api.download(path, { account_id }, IMAGE_BYTES_MAX);
|
|
3069
|
+
return textWithImage(res, image, imageType);
|
|
3070
|
+
}
|
|
3071
|
+
catch (e) {
|
|
3072
|
+
return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
3073
|
+
}
|
|
3074
|
+
});
|
|
2902
3075
|
server.registerTool("lighthouse_inbox_email_create_draft", {
|
|
2903
3076
|
title: "Create Email Draft",
|
|
2904
3077
|
description: "Save an email draft in one of the user's mailboxes. The draft is NOT sent: it lands in the mailbox for the " +
|
|
@@ -3022,8 +3195,8 @@ export function createServer(apiKey) {
|
|
|
3022
3195
|
description: "Calendars the API key's user can read: their own connected calendar (is_owner true) and colleagues' calendars shared with them. " +
|
|
3023
3196
|
"The same account object the email and LinkedIn channels return: { id, name, email, is_owner, provider (google | microsoft | other), " +
|
|
3024
3197
|
"status (connected | pending | disconnected), status_reason (credentials | error | stopped | other; null unless disconnected), " +
|
|
3025
|
-
"owner, permissions { calendar_view }, created_at }. " +
|
|
3026
|
-
"A calendar is the same connected account as a mailbox, behind its own sharing switch, so a mailbox shared for mail only is NOT listed here. " +
|
|
3198
|
+
"owner, permissions { calendar_view, meeting_notes_view }, created_at }: calendar_view reads the calendar's meetings, meeting_notes_view reads the notes and transcripts of the meetings its owner recorded, including meetings the user was not invited to. " +
|
|
3199
|
+
"A calendar is the same connected account as a mailbox, behind its own sharing switch, so a mailbox shared for mail only is NOT listed here, and neither is a calendar shared for meeting notes only: those notes come through the note tools, and each one's meeting.meeting_key opens its transcript. " +
|
|
3027
3200
|
"Use scope 'me' to get only the user's own calendar: the SAME object, or null. " +
|
|
3028
3201
|
"Use it to know whose meetings lighthouse_inbox_calendar_meetings can see, or to get an account_id to narrow that tool to one calendar. Returns [] when no calendar is connected.",
|
|
3029
3202
|
inputSchema: {
|
|
@@ -3044,7 +3217,7 @@ export function createServer(apiKey) {
|
|
|
3044
3217
|
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). " +
|
|
3045
3218
|
"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. " +
|
|
3046
3219
|
"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. " +
|
|
3047
|
-
"Each row: { id, meeting_key (only when the meeting was recorded), ical_uid, title, description, location, start, end, is_all_day, is_recurring (a recurring meeting appears once per occurrence), status (confirmed | tentative | cancelled | null), meeting_url (the video link), organizer { name, email }, attendees [{ name, email, response_status }] (everyone invited, the calendar's own account included; response_status is accepted | declined | tentative | needs_action, where needs_action means that attendee has not answered yet; a missing title, description, location, name or email is null, never an empty string), account { id, name, email, is_owner } (the calendar it was read from), recording: null when not recorded, else { has_recording, has_transcript, has_notes, duration_seconds, language, recorded_by }, the same object lighthouse_inbox_calendar_meeting returns (recorded_by is the recorder as a workspace member { id, first_name, last_name, full_name, avatar_url, email }, or null; has_notes is true only when notes are ready and the user may
|
|
3220
|
+
"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) }. " +
|
|
3048
3221
|
"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. " +
|
|
3049
3222
|
"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).",
|
|
3050
3223
|
inputSchema: {
|
|
@@ -3074,10 +3247,10 @@ export function createServer(apiKey) {
|
|
|
3074
3247
|
});
|
|
3075
3248
|
server.registerTool("lighthouse_inbox_calendar_meeting", {
|
|
3076
3249
|
title: "Get Recorded Meeting",
|
|
3077
|
-
description: "One recorded meeting's metadata by meeting_key (
|
|
3078
|
-
"WHEN TO USE: to check whether a transcript or notes exist before reading them, or to answer how long a call lasted. It never returns the transcript or the notes themselves. Not found when the meeting was not recorded or the API key's user
|
|
3250
|
+
description: "One recorded meeting's metadata by meeting_key (" + MEETING_KEY_SOURCES + "): { meeting_key, ical_uid, title, occurred_at, meeting_url (the video link), recording { has_recording, has_transcript, has_notes, duration_seconds, language, recorded_by } (the same object the meetings list carries; recorded_by is the recorder as a workspace member, or null), notes { id, status, created_at }: the note lighthouse_inbox_calendar_meeting_notes returns, or null when there is none the user may read }. The title and the time are the same here as on the meetings list and inside the notes. " +
|
|
3251
|
+
"WHEN TO USE: to check whether a transcript or notes exist before reading them, or to answer how long a call lasted. It never returns the transcript or the notes themselves, and the API never returns the recording itself. Not found when the meeting was not recorded or the API key's user may not read it. A recurring meeting keeps the recording of its most recently recorded occurrence. " + MEETING_ACCESS_RULE,
|
|
3079
3252
|
inputSchema: {
|
|
3080
|
-
meeting_key: z.string().describe("The meeting_key of a recorded meeting"),
|
|
3253
|
+
meeting_key: z.string().describe("The meeting_key of a recorded meeting, " + MEETING_KEY_SOURCES),
|
|
3081
3254
|
},
|
|
3082
3255
|
}, async (params) => {
|
|
3083
3256
|
try {
|
|
@@ -3090,11 +3263,11 @@ export function createServer(apiKey) {
|
|
|
3090
3263
|
});
|
|
3091
3264
|
server.registerTool("lighthouse_inbox_calendar_meeting_notes", {
|
|
3092
3265
|
title: "Get Meeting Notes",
|
|
3093
|
-
description: "The meeting notes written from one recorded meeting, by meeting_key: the note in the same shape as lighthouse_crm_get_note (type 'meeting', with the meeting object and the records it is linked to), plus content_markdown (what was generated: Meeting Context first, then the sections) and is_edited (true when a member changed the note since, in which case content is the version to trust). " +
|
|
3266
|
+
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). " +
|
|
3094
3267
|
"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. " +
|
|
3095
|
-
"Not found when the meeting has no notes yet (see has_notes on the meeting) or the API key's user
|
|
3268
|
+
"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,
|
|
3096
3269
|
inputSchema: {
|
|
3097
|
-
meeting_key: z.string().describe("The meeting_key of a recorded meeting"),
|
|
3270
|
+
meeting_key: z.string().describe("The meeting_key of a recorded meeting, " + MEETING_KEY_SOURCES),
|
|
3098
3271
|
},
|
|
3099
3272
|
}, async (params) => {
|
|
3100
3273
|
try {
|
|
@@ -3107,12 +3280,12 @@ export function createServer(apiKey) {
|
|
|
3107
3280
|
});
|
|
3108
3281
|
server.registerTool("lighthouse_inbox_calendar_meeting_transcript", {
|
|
3109
3282
|
title: "Get Meeting Transcript",
|
|
3110
|
-
description: "The transcript of one recorded meeting by meeting_key, as text: one \"Speaker: text\" line per utterance, in order. " +
|
|
3283
|
+
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. " +
|
|
3111
3284
|
"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. " +
|
|
3112
3285
|
`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. ` +
|
|
3113
|
-
"Not found when the meeting has no transcript or the API key's user
|
|
3286
|
+
"Not found when the meeting has no transcript or the API key's user may not read it. " + MEETING_ACCESS_RULE,
|
|
3114
3287
|
inputSchema: {
|
|
3115
|
-
meeting_key: z.string().describe("The meeting_key of a recorded meeting"),
|
|
3288
|
+
meeting_key: z.string().describe("The meeting_key of a recorded meeting, " + MEETING_KEY_SOURCES),
|
|
3116
3289
|
offset_chars: z.number().min(0).default(0).describe("Character offset to start from (meta.next_offset_chars of the previous window)"),
|
|
3117
3290
|
max_chars: z.number().min(1).default(TRANSCRIPT_WINDOW_CHARS).describe("Characters per window"),
|
|
3118
3291
|
},
|
|
@@ -3125,5 +3298,68 @@ export function createServer(apiKey) {
|
|
|
3125
3298
|
return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
3126
3299
|
}
|
|
3127
3300
|
});
|
|
3301
|
+
if (options.privateEquity !== true)
|
|
3302
|
+
return server;
|
|
3303
|
+
server.registerTool("lighthouse_private_equity_list_saved_searches", {
|
|
3304
|
+
title: "List Private Equity Saved Searches",
|
|
3305
|
+
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. " +
|
|
3306
|
+
"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. " +
|
|
3307
|
+
"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. " +
|
|
3308
|
+
"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. " +
|
|
3309
|
+
"These are separate from the Discovery saved searches (lighthouse_discovery_list_saved_searches), which search a different database.",
|
|
3310
|
+
inputSchema: {
|
|
3311
|
+
offset: z.number().int().min(0).optional().describe("Number of saved searches to skip (default 0)"),
|
|
3312
|
+
limit: z.number().int().min(1).max(100).optional().describe("Saved searches per page, 1 to 100 (default 25)"),
|
|
3313
|
+
},
|
|
3314
|
+
}, async ({ offset, limit }) => {
|
|
3315
|
+
try {
|
|
3316
|
+
const res = await api.get("/private-equity/saved-searches", { offset, limit });
|
|
3317
|
+
return text(res);
|
|
3318
|
+
}
|
|
3319
|
+
catch (e) {
|
|
3320
|
+
return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
3321
|
+
}
|
|
3322
|
+
});
|
|
3323
|
+
server.registerTool("lighthouse_private_equity_list_saved_search_results", {
|
|
3324
|
+
title: "List Private Equity Saved Search Results",
|
|
3325
|
+
description: "Read one page of the companies a Private Equity saved search's LAST run stored, in the order that run returned them. " +
|
|
3326
|
+
"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). " +
|
|
3327
|
+
"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. " +
|
|
3328
|
+
"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. " +
|
|
3329
|
+
"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. " +
|
|
3330
|
+
"Not found when the saved search does not exist or the user cannot see it.",
|
|
3331
|
+
inputSchema: {
|
|
3332
|
+
search_id: z.string().uuid().describe("Private Equity saved search ID (from lighthouse_private_equity_list_saved_searches)"),
|
|
3333
|
+
offset: z.number().int().min(0).optional().describe("Number of companies to skip (default 0)"),
|
|
3334
|
+
limit: z.number().int().min(1).max(100).optional().describe("Companies per page, 1 to 100 (default 25)"),
|
|
3335
|
+
},
|
|
3336
|
+
}, async ({ search_id, offset, limit }) => {
|
|
3337
|
+
try {
|
|
3338
|
+
const res = await api.get(`/private-equity/saved-searches/${encodeURIComponent(search_id)}/results`, { offset, limit });
|
|
3339
|
+
return text(res);
|
|
3340
|
+
}
|
|
3341
|
+
catch (e) {
|
|
3342
|
+
return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
3343
|
+
}
|
|
3344
|
+
});
|
|
3345
|
+
server.registerTool("lighthouse_private_equity_get_company", {
|
|
3346
|
+
title: "Get Private Equity Company",
|
|
3347
|
+
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). " +
|
|
3348
|
+
"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. " +
|
|
3349
|
+
"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. " +
|
|
3350
|
+
"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. " +
|
|
3351
|
+
"Not found when none of this workspace's Private Equity searches returned that company.",
|
|
3352
|
+
inputSchema: {
|
|
3353
|
+
company_id: z.string().min(1).describe("The company's id, as a row of lighthouse_private_equity_list_saved_search_results gives it"),
|
|
3354
|
+
},
|
|
3355
|
+
}, async ({ company_id }) => {
|
|
3356
|
+
try {
|
|
3357
|
+
const res = await api.get(`/private-equity/companies/${encodeURIComponent(company_id)}`);
|
|
3358
|
+
return text(res);
|
|
3359
|
+
}
|
|
3360
|
+
catch (e) {
|
|
3361
|
+
return err(`Failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
3362
|
+
}
|
|
3363
|
+
});
|
|
3128
3364
|
return server;
|
|
3129
3365
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trylighthouse/mcp-server",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.10",
|
|
4
4
|
"description": "MCP server for the Lighthouse CRM API",
|
|
5
5
|
"homepage": "https://trylighthouse.vc",
|
|
6
6
|
"type": "module",
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"scripts": {
|
|
21
21
|
"build": "rm -rf build && tsc && chmod 755 build/index.js && node scripts/check-no-comments.mjs",
|
|
22
22
|
"dev": "tsc --watch",
|
|
23
|
+
"test": "node --test test/*.test.mjs",
|
|
23
24
|
"prepublishOnly": "npm run build"
|
|
24
25
|
},
|
|
25
26
|
"dependencies": {
|