@adrata/adrata-mcp 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +548 -0
  2. package/access/auth.js +289 -0
  3. package/access/oauth.js +1059 -0
  4. package/access/resource-metadata.js +167 -0
  5. package/access/tiers.js +422 -0
  6. package/analytics.js +634 -0
  7. package/api-bridge.js +499 -0
  8. package/governance/money.js +141 -0
  9. package/output-formatter.js +589 -0
  10. package/package.json +68 -0
  11. package/resources.js +246 -0
  12. package/security.js +690 -0
  13. package/server.js +2139 -0
  14. package/server.json +55 -0
  15. package/skills/backlog-triage/SKILL.md +115 -0
  16. package/skills/board-review/SKILL.md +96 -0
  17. package/skills/incident-to-card/SKILL.md +126 -0
  18. package/skills/log-outreach.md +62 -0
  19. package/skills/ship-the-card/SKILL.md +155 -0
  20. package/tool-annotations.js +269 -0
  21. package/tools/billing.js +149 -0
  22. package/tools/email-tools.js +652 -0
  23. package/tools/enterprise-tools.js +651 -0
  24. package/tools/free-search.js +160 -0
  25. package/tools/memory.js +440 -0
  26. package/tools/morning-brief.js +551 -0
  27. package/tools/paper-tools.js +563 -0
  28. package/tools/scheduling.js +322 -0
  29. package/tools/work-board-tools.js +758 -0
  30. package/toolsets/communications.js +276 -0
  31. package/toolsets/crm.js +495 -0
  32. package/toolsets/extensibility.js +1131 -0
  33. package/toolsets/infrastructure.js +757 -0
  34. package/toolsets/intelligence.js +232 -0
  35. package/toolsets/knowledge.js +154 -0
  36. package/toolsets/matrix.js +217 -0
  37. package/toolsets/outreach.js +432 -0
  38. package/toolsets/prospecting.js +314 -0
  39. package/toolsets/revenue/always-loaded.js +341 -0
  40. package/toolsets/revenue/sloan-tools.js +81 -0
  41. package/transport-http.js +505 -0
@@ -0,0 +1,314 @@
1
+ /**
2
+ * Prospecting Toolset (6 tools, Pro tier)
3
+ *
4
+ * qualify_company, research_company, research_person,
5
+ * get_priority_pursuits, get_speedrun_list (legacy alias), discover_prospects,
6
+ * get_next_contacts
7
+ */
8
+
9
+ import { z } from 'zod';
10
+ import {
11
+ md, mdError,
12
+ formatQualification, formatCompanyResearch, formatPersonResearch,
13
+ formatSpeedrunList, formatNextContacts, table, signalEmoji, formatDate, daysSince,
14
+ } from '../output-formatter.js';
15
+
16
+ export function register(server, api, AUTH) {
17
+
18
+ // -----------------------------------------------------------------------
19
+ // qualify_company
20
+ // -----------------------------------------------------------------------
21
+ server.tool(
22
+ 'qualify_company',
23
+ 'Qualify a company against your ICP Matrix. Returns a coaching narrative with score, intent signals, deal authority, and recommended next move. Orchestrates 4+ API calls into one result.',
24
+ {
25
+ companyId: z.string().optional().describe('Company ID (if known)'),
26
+ name: z.string().optional().describe('Company name (used to search if no ID)'),
27
+ },
28
+ async (args) => {
29
+ try {
30
+ // Resolve company
31
+ let companyId = args.companyId;
32
+ let company;
33
+
34
+ if (!companyId && args.name) {
35
+ const search = await api('GET', '/api/v1/companies', { params: { search: args.name, limit: 3, page: 1 } });
36
+ const match = (search?.data || [])[0];
37
+ if (!match) return mdError(`Company "${args.name}" not found`, 'Try a different name or create the company first.');
38
+ companyId = match.id;
39
+ company = match;
40
+ } else if (companyId) {
41
+ company = await api('GET', `/api/v1/companies/${companyId}`);
42
+ company = company?.data || company;
43
+ } else {
44
+ return mdError('Provide companyId or name');
45
+ }
46
+
47
+ // Parallel: ICP score, signals, authority
48
+ const [icpScore, signals, authority] = await Promise.all([
49
+ api('POST', `/api/v1/icp-scoring/score-company/${encodeURIComponent(companyId)}`, { body: {} }).catch(() => ({ score: 0 })),
50
+ api('GET', '/api/v1/intent-signals', { params: { companyId } }).catch(() => ({ data: [] })),
51
+ api('GET', '/api/v1/deal-authority/buyer-intelligence', { params: { opportunityId: companyId } }).catch(() => ({})),
52
+ ]);
53
+
54
+ return md(formatQualification(company, icpScore, signals, authority));
55
+ } catch (err) {
56
+ return mdError('Qualification failed', err.message);
57
+ }
58
+ }
59
+ );
60
+
61
+ // -----------------------------------------------------------------------
62
+ // research_company
63
+ // -----------------------------------------------------------------------
64
+ server.tool(
65
+ 'research_company',
66
+ 'Deep research report on a company: firmographics, tech stack, funding, news, competitors, intent signals. Orchestrates 5+ API calls into a structured report.',
67
+ {
68
+ companyId: z.string().optional().describe('Company ID'),
69
+ name: z.string().optional().describe('Company name to search'),
70
+ },
71
+ async (args) => {
72
+ try {
73
+ let companyId = args.companyId;
74
+ let company;
75
+
76
+ if (!companyId && args.name) {
77
+ const search = await api('GET', '/api/v1/companies', { params: { search: args.name, limit: 3, page: 1 } });
78
+ const match = (search?.data || [])[0];
79
+ if (!match) return mdError(`Company "${args.name}" not found`);
80
+ companyId = match.id;
81
+ company = match;
82
+ } else if (companyId) {
83
+ company = await api('GET', `/api/v1/companies/${companyId}`);
84
+ company = company?.data || company;
85
+ } else {
86
+ return mdError('Provide companyId or name');
87
+ }
88
+
89
+ // Parallel enrichment
90
+ const [enrichment, signals, competitors] = await Promise.all([
91
+ api('POST', `/api/v1/companies/${companyId}/enrich`).catch(() => ({})),
92
+ api('GET', '/api/v1/intent-signals', { params: { companyId } }).catch(() => ({ data: [] })),
93
+ api('GET', '/api/v1/competitors', { params: { companyId } }).catch(() => ({ data: [] })),
94
+ ]);
95
+
96
+ return md(formatCompanyResearch(company, enrichment, signals, competitors));
97
+ } catch (err) {
98
+ return mdError('Research failed', err.message);
99
+ }
100
+ }
101
+ );
102
+
103
+ // -----------------------------------------------------------------------
104
+ // research_person
105
+ // -----------------------------------------------------------------------
106
+ server.tool(
107
+ 'research_person',
108
+ 'Deep research on a person: professional profile, communication preferences, intro paths, recent activity. Orchestrates 3+ API calls.',
109
+ {
110
+ personId: z.string().optional().describe('Person ID'),
111
+ name: z.string().optional().describe('Person name to search'),
112
+ email: z.string().optional().describe('Email to search by'),
113
+ },
114
+ async (args) => {
115
+ try {
116
+ let personId = args.personId;
117
+ let person;
118
+
119
+ if (!personId && (args.name || args.email)) {
120
+ const q = args.email || args.name;
121
+ const search = await api('GET', '/api/v1/people', { params: { search: q, limit: 5, page: 1 } });
122
+ const match = args.email
123
+ ? (search?.data || []).find(p => p.email?.toLowerCase() === args.email.toLowerCase()) || (search?.data || [])[0]
124
+ : (search?.data || [])[0];
125
+ if (!match) return mdError(`Person not found: "${q}"`);
126
+ personId = match.id;
127
+ person = match;
128
+ } else if (personId) {
129
+ person = await api('GET', `/api/v1/people/${personId}`);
130
+ person = person?.data || person;
131
+ } else {
132
+ return mdError('Provide personId, name, or email');
133
+ }
134
+
135
+ // Parallel: enrichment + actions
136
+ const [enrichment, actions] = await Promise.all([
137
+ api('POST', `/api/v1/people/${personId}/enrich`).catch(() => ({})),
138
+ api('GET', '/api/v1/actions', { params: { personId, limit: 10, page: 1 } }).catch(() => ({ data: [] })),
139
+ ]);
140
+
141
+ return md(formatPersonResearch(person, enrichment, actions));
142
+ } catch (err) {
143
+ return mdError('Research failed', err.message);
144
+ }
145
+ }
146
+ );
147
+
148
+ // -----------------------------------------------------------------------
149
+ // get_priority_pursuits
150
+ // -----------------------------------------------------------------------
151
+ server.tool(
152
+ 'get_priority_pursuits',
153
+ 'Get today\'s evidence-ranked Priority Pursuits with coaching context. Each item includes the person, company, signal score, and recommended next move.',
154
+ {
155
+ limit: z.number().optional().describe('Number of items (default 25)'),
156
+ },
157
+ async (args) => {
158
+ try {
159
+ const data = await api('GET', '/api/v1/speedrun', { params: { limit: args.limit || 25, page: 1 } });
160
+ return md(formatSpeedrunList(data));
161
+ } catch (err) {
162
+ return mdError('Could not load Priority Pursuits', err.message);
163
+ }
164
+ }
165
+ );
166
+
167
+ // Compatibility alias for clients that have not refreshed their tool list.
168
+ server.tool(
169
+ 'get_speedrun_list',
170
+ 'Legacy alias for get_priority_pursuits.',
171
+ {
172
+ limit: z.number().optional().describe('Number of items (default 25)'),
173
+ },
174
+ async (args) => {
175
+ try {
176
+ const data = await api('GET', '/api/v1/speedrun', { params: { limit: args.limit || 25, page: 1 } });
177
+ return md(formatSpeedrunList(data));
178
+ } catch (err) {
179
+ return mdError('Could not load Priority Pursuits', err.message);
180
+ }
181
+ }
182
+ );
183
+
184
+ // -----------------------------------------------------------------------
185
+ // discover_prospects
186
+ // -----------------------------------------------------------------------
187
+ server.tool(
188
+ 'discover_prospects',
189
+ 'Find companies or people matching criteria. Search by industry, size, technology, title, or keywords. Returns qualified matches with ICP Matrix scores.',
190
+ {
191
+ type: z.enum(['companies', 'people']).optional().describe('Search for companies or people (default: companies)'),
192
+ industry: z.string().optional().describe('Industry filter'),
193
+ size: z.string().optional().describe('Company size filter (e.g. "100-500")'),
194
+ title: z.string().optional().describe('Job title filter (people search)'),
195
+ keywords: z.string().optional().describe('Free-text search keywords'),
196
+ limit: z.number().optional().describe('Max results (default 25)'),
197
+ },
198
+ async (args) => {
199
+ try {
200
+ const type = args.type || 'companies';
201
+ const endpoint = type === 'people' ? '/api/v1/people' : '/api/v1/companies';
202
+ const params = {
203
+ search: args.keywords,
204
+ industry: args.industry,
205
+ limit: args.limit || 25,
206
+ page: 1,
207
+ };
208
+ if (args.title) params.title = args.title;
209
+ if (args.size) params.size = args.size;
210
+
211
+ const data = await api('GET', endpoint, { params });
212
+ const items = data?.data || [];
213
+
214
+ if (items.length === 0) {
215
+ return md(`## Prospect Discovery\n\nNo ${type} found matching your criteria. Try broadening your search.\n`);
216
+ }
217
+
218
+ let text = `## Prospect Discovery \u2014 ${items.length} ${type}\n\n`;
219
+
220
+ if (type === 'companies') {
221
+ const rows = items.map((c, i) => [
222
+ String(i + 1),
223
+ c.name || '\u2014',
224
+ c.industry || '\u2014',
225
+ c.employeeCount || c.size || '\u2014',
226
+ c.status || '\u2014',
227
+ ]);
228
+ text += table(['#', 'Company', 'Industry', 'Size', 'Status'], rows);
229
+ } else {
230
+ const rows = items.map((p, i) => [
231
+ String(i + 1),
232
+ p.name || `${p.firstName} ${p.lastName}`,
233
+ p.title || '\u2014',
234
+ p.companyName || '\u2014',
235
+ p.email || '\u2014',
236
+ ]);
237
+ text += table(['#', 'Name', 'Title', 'Company', 'Email'], rows);
238
+ }
239
+
240
+ return md(text);
241
+ } catch (err) {
242
+ return mdError('Discovery failed', err.message);
243
+ }
244
+ }
245
+ );
246
+
247
+ // -----------------------------------------------------------------------
248
+ // get_next_contacts
249
+ // -----------------------------------------------------------------------
250
+ server.tool(
251
+ 'get_next_contacts',
252
+ 'Who should I contact next? Returns a ranked list with last touch, days since touch, signal score, and recommended next move. Combines overdue tasks, stale contacts, and hot signals.',
253
+ {
254
+ limit: z.number().optional().describe('Max contacts to return (default 10)'),
255
+ },
256
+ async (args) => {
257
+ try {
258
+ const limit = args.limit || 10;
259
+
260
+ // Parallel: overdue actions, today's actions, speedrun list
261
+ const [overdue, today, speedrun] = await Promise.all([
262
+ api('GET', '/api/v1/actions/overdue', { params: { limit: 25, page: 1 } }).catch(() => ({ data: [] })),
263
+ api('GET', '/api/v1/actions/today', { params: { limit: 25, page: 1 } }).catch(() => ({ data: [] })),
264
+ api('GET', '/api/v1/speedrun', { params: { limit, page: 1 } }).catch(() => ({ data: [] })),
265
+ ]);
266
+
267
+ // Merge and deduplicate by personId
268
+ const seen = new Set();
269
+ const contacts = [];
270
+
271
+ // Priority Pursuit items first (already ranked)
272
+ for (const item of (speedrun?.data || [])) {
273
+ const key = item.personId || item.name;
274
+ if (!seen.has(key)) {
275
+ seen.add(key);
276
+ contacts.push({
277
+ name: item.personName || item.name,
278
+ companyName: item.companyName,
279
+ signal: item.reason || item.signal,
280
+ signalType: item.signalType || 'intent',
281
+ lastActionType: item.lastActionType,
282
+ lastActionDate: item.lastActionDate,
283
+ daysSinceTouch: item.lastActionDate ? daysSince(item.lastActionDate) : null,
284
+ nextAction: item.nextAction || 'Follow up',
285
+ });
286
+ }
287
+ }
288
+
289
+ // Add overdue actions
290
+ for (const action of (overdue?.data || [])) {
291
+ const key = action.personId || action.title;
292
+ if (!seen.has(key)) {
293
+ seen.add(key);
294
+ contacts.push({
295
+ name: action.personName || action.title,
296
+ companyName: action.companyName,
297
+ signal: 'Overdue task',
298
+ signalType: 'intent',
299
+ lastActionType: action.type,
300
+ lastActionDate: action.dueDate,
301
+ daysSinceTouch: action.dueDate ? daysSince(action.dueDate) : null,
302
+ nextAction: action.title,
303
+ });
304
+ }
305
+ }
306
+
307
+ const overdueCount = (overdue?.data || []).length;
308
+ return md(formatNextContacts(contacts.slice(0, limit), overdueCount, 0));
309
+ } catch (err) {
310
+ return mdError('Could not load next contacts', err.message);
311
+ }
312
+ }
313
+ );
314
+ }
@@ -0,0 +1,341 @@
1
+ /**
2
+ * Always-Loaded Toolset (6 tools)
3
+ *
4
+ * These tools are registered at startup and always available in context.
5
+ * They include toolset discovery, free-tier search, and inbox check.
6
+ */
7
+
8
+ import { z } from 'zod';
9
+ import { md, mdError, formatInbox } from '../../output-formatter.js';
10
+ import { getDemoAvailability } from '../../tools/scheduling.js';
11
+
12
+ export const TOOLSET_REGISTRY = {
13
+ prospecting: {
14
+ name: 'prospecting',
15
+ description: 'Qualify companies, research prospects, get daily priorities, discover leads',
16
+ tier: 'pro',
17
+ toolCount: 6,
18
+ },
19
+ intelligence: {
20
+ name: 'intelligence',
21
+ description: 'Competitive intel, meeting briefs, deal coaching, signals, forecasting',
22
+ tier: 'pro',
23
+ toolCount: 5,
24
+ },
25
+ outreach: {
26
+ name: 'outreach',
27
+ description: 'Draft/send emails, manage sequences, analytics, network paths, search emails',
28
+ tier: 'pro',
29
+ toolCount: 8,
30
+ },
31
+ crm: {
32
+ name: 'crm',
33
+ description:
34
+ 'Full CRUD (create, read, update, delete) for companies, people, opportunities, activities, and buyer groups — including deleting a buyer room and its members',
35
+ tier: 'enterprise',
36
+ toolCount: 6,
37
+ },
38
+ communications: {
39
+ name: 'communications',
40
+ description: 'Phone calls, SMS, call transcripts, calendar, meeting scheduling',
41
+ tier: 'enterprise',
42
+ toolCount: 5,
43
+ },
44
+ infrastructure: {
45
+ name: 'infrastructure',
46
+ description: 'Domains, mailboxes, deliverability, OAuth providers, workspace, data management',
47
+ tier: 'enterprise',
48
+ toolCount: 6,
49
+ },
50
+ knowledge: {
51
+ name: 'knowledge',
52
+ description:
53
+ 'Search wiki pages, account wikis, create battle cards/playbooks, link files to CRM records',
54
+ tier: 'pro',
55
+ toolCount: 4,
56
+ },
57
+ matrix: {
58
+ name: 'matrix',
59
+ description:
60
+ 'Ask Matrix analytics, inspect forecast risk, review anomalies, and turn GTM intelligence into action',
61
+ tier: 'pro',
62
+ toolCount: 25,
63
+ },
64
+ };
65
+
66
+ export function register(server, api, AUTH) {
67
+ // -----------------------------------------------------------------------
68
+ // list_toolsets -- discover available toolset groups
69
+ // -----------------------------------------------------------------------
70
+ server.tool(
71
+ 'list_toolsets',
72
+ 'List available composite toolsets. Each toolset contains multiple high-level tools. Use enable_toolset to load one into your session. Only toolsets your tier can access are shown.',
73
+ {},
74
+ async () => {
75
+ const tierRank = { free: 0, pro: 1, enterprise: 2 };
76
+ const userRank = tierRank[AUTH.tier] ?? 0;
77
+
78
+ const rows = [];
79
+ for (const ts of Object.values(TOOLSET_REGISTRY)) {
80
+ const accessible = tierRank[ts.tier] <= userRank;
81
+ rows.push([
82
+ ts.name,
83
+ `${ts.toolCount} tools`,
84
+ ts.description,
85
+ accessible ? '\u2705' : `\uD83D\uDD12 ${ts.tier}`,
86
+ ]);
87
+ }
88
+
89
+ let text = '## Available Toolsets\n\n';
90
+ text += '| Toolset | Tools | Description | Access |\n';
91
+ text += '|---------|-------|-------------|--------|\n';
92
+ rows.forEach((r) => {
93
+ text += `| ${r.join(' | ')} |\n`;
94
+ });
95
+ text += '\nUse `enable_toolset` with the toolset name to load its tools.\n';
96
+ return md(text);
97
+ }
98
+ );
99
+
100
+ // -----------------------------------------------------------------------
101
+ // enable_toolset -- load a toolset's tools into the session
102
+ // -----------------------------------------------------------------------
103
+ server.tool(
104
+ 'enable_toolset',
105
+ "Load a toolset into the current session. This registers all tools in the named toolset so you can call them. Use list_toolsets first to see what's available.",
106
+ {
107
+ name: z
108
+ .string()
109
+ .describe(
110
+ 'Toolset name: prospecting, intelligence, outreach, crm, communications, infrastructure, knowledge, matrix'
111
+ ),
112
+ },
113
+ async (args) => {
114
+ const ts = TOOLSET_REGISTRY[args.name];
115
+ if (!ts) {
116
+ return mdError(
117
+ `Unknown toolset: "${args.name}"`,
118
+ `Available toolsets: ${Object.keys(TOOLSET_REGISTRY).join(', ')}`
119
+ );
120
+ }
121
+
122
+ const tierRank = { free: 0, pro: 1, enterprise: 2 };
123
+ if ((tierRank[ts.tier] ?? 0) > (tierRank[AUTH.tier] ?? 0)) {
124
+ return mdError(
125
+ `Toolset "${args.name}" requires ${ts.tier} tier.`,
126
+ `Your current tier is ${AUTH.tier}. Use connect_workspace or upgrade_account to access higher tiers.`
127
+ );
128
+ }
129
+
130
+ // Check if already loaded
131
+ if (ts._loaded) {
132
+ return md(`## ${ts.name}\n\nAlready loaded. ${ts.toolCount} tools available.\n`);
133
+ }
134
+
135
+ // Dynamically import and register.
136
+ // The toolset modules live in code/mcp/toolsets/, one level UP from this
137
+ // file's own directory (code/mcp/toolsets/revenue/). PR #502 moved this
138
+ // file into revenue/ without updating the specifier, which left every
139
+ // toolset permanently unloadable ("Cannot find module .../revenue/crm.js").
140
+ try {
141
+ const module = await import(`../${ts.name}.js`);
142
+ const skipped = [];
143
+ const realTool = server.tool.bind(server);
144
+ const dedupingServer = Object.create(server);
145
+ dedupingServer.tool = (name, ...rest) => {
146
+ try {
147
+ return realTool(name, ...rest);
148
+ } catch (err) {
149
+ if (/already registered/i.test(String(err?.message || err))) {
150
+ skipped.push(name);
151
+ return undefined;
152
+ }
153
+ throw err;
154
+ }
155
+ };
156
+ module.register(dedupingServer, api, AUTH);
157
+ ts._loaded = true;
158
+
159
+ return md(
160
+ `## ${ts.name} \u2014 Loaded\n\n${ts.toolCount} tools now available. ${ts.description}.\n`
161
+ );
162
+ } catch (err) {
163
+ return mdError(`Failed to load toolset "${args.name}": ${err.message}`);
164
+ }
165
+ }
166
+ );
167
+
168
+ // -----------------------------------------------------------------------
169
+ // find_company -- Claude-powered free search (delegates to free-search.js)
170
+ // -----------------------------------------------------------------------
171
+ // Note: find_company and find_person are already registered in server.js
172
+ // as free-tier tools. We don't re-register them here to avoid conflicts.
173
+ // They are always available by default.
174
+
175
+ // -----------------------------------------------------------------------
176
+ // get_demo_availability -- free tier scheduling via Cal.com
177
+ // -----------------------------------------------------------------------
178
+ server.tool(
179
+ 'get_demo_availability',
180
+ 'Check available demo time slots from Cal.com. Returns the next 5 available slots for scheduling a product demo. Free tier — no account needed. Falls back to a booking link if Cal.com API is unavailable.',
181
+ {
182
+ timezone: z
183
+ .string()
184
+ .optional()
185
+ .describe('IANA timezone (e.g. "America/New_York"). Auto-detected if omitted.'),
186
+ },
187
+ async (args) => {
188
+ try {
189
+ const result = await getDemoAvailability(args.timezone);
190
+
191
+ if (result.fallback) {
192
+ let text = '## Demo Availability\n\n';
193
+ text += result.message + '\n';
194
+ return md(text);
195
+ }
196
+
197
+ let text = '## Demo Availability\n\n';
198
+ const rows = result.slots.map((s, i) => [
199
+ `${i + 1}`,
200
+ s.date || '\u2014',
201
+ s.time || '\u2014',
202
+ s.duration || '30 min',
203
+ ]);
204
+ text += `Timezone: ${result.timezone}\n\n`;
205
+ text += '| # | Date | Time | Duration |\n|---|------|------|----------|\n';
206
+ rows.forEach((r) => {
207
+ text += `| ${r.join(' | ')} |\n`;
208
+ });
209
+ text += '\nUse `schedule_demo` with a date and time to book.\n';
210
+ return md(text);
211
+ } catch (err) {
212
+ return mdError('Could not fetch demo availability', err.message);
213
+ }
214
+ }
215
+ );
216
+
217
+ // -----------------------------------------------------------------------
218
+ // check_inbox -- cross-references replies with CRM + sequences
219
+ // -----------------------------------------------------------------------
220
+ server.tool(
221
+ 'check_inbox',
222
+ 'Check your inbox for new replies. Cross-references with CRM contacts and active sequences to classify reply types (positive, OOO, meeting request, not interested) and suggest Next Moves. The "Did anyone get back to me?" tool.',
223
+ {
224
+ since: z
225
+ .string()
226
+ .optional()
227
+ .describe('Check replies since date (ISO string, default: yesterday)'),
228
+ },
229
+ async (args) => {
230
+ try {
231
+ // An empty email feed is not evidence of zero replies when no inbox has been connected.
232
+ // Check coverage first so Adrata never reports a false all-clear.
233
+ const providerResponse = await api('GET', '/api/v1/oauth/email/providers');
234
+ const providers = Array.isArray(providerResponse?.data)
235
+ ? providerResponse.data
236
+ : Array.isArray(providerResponse)
237
+ ? providerResponse
238
+ : [];
239
+ const connectedProviders = providers.filter(
240
+ (provider) =>
241
+ provider && provider.status !== 'disconnected' && provider.status !== 'revoked'
242
+ );
243
+ if (connectedProviders.length === 0) {
244
+ return md(
245
+ '## Inbox not connected\n\n' +
246
+ 'Adrata cannot check replies yet because this workspace has no connected Google or Microsoft inbox. ' +
247
+ 'Connect an inbox in Desktop → Settings → Connections, wait for the first sync to succeed, then run `check_inbox` again.\n'
248
+ );
249
+ }
250
+
251
+ // Fetch recent emails
252
+ const since = args.since || new Date(Date.now() - 86400000).toISOString();
253
+ const emails = await api('GET', '/api/v1/emails', {
254
+ params: { folder: 'inbox', since, limit: 50, page: 1 },
255
+ });
256
+ const replies = (emails?.data || []).filter(
257
+ (e) => e.isReply || e.inReplyTo || (e.subject && e.subject.startsWith('Re:'))
258
+ );
259
+
260
+ if (replies.length === 0) {
261
+ return md(
262
+ '## Inbox\n\nNo new replies were found in the connected inboxes for this period.\n'
263
+ );
264
+ }
265
+
266
+ // Enrich with CRM data where possible
267
+ const enriched = await Promise.all(
268
+ replies.slice(0, 15).map(async (email) => {
269
+ let companyName = email.companyName || '';
270
+ let replyType = 'unknown';
271
+ let suggestedAction = '';
272
+
273
+ // Try to classify reply
274
+ const bodyLower = (email.body || email.snippet || '').toLowerCase();
275
+ if (
276
+ bodyLower.includes('out of office') ||
277
+ bodyLower.includes('ooo') ||
278
+ bodyLower.includes('vacation')
279
+ ) {
280
+ replyType = 'ooo';
281
+ suggestedAction = 'Paused \u2014 follow up when back';
282
+ } else if (
283
+ bodyLower.includes('not interested') ||
284
+ bodyLower.includes('unsubscribe') ||
285
+ bodyLower.includes('remove me')
286
+ ) {
287
+ replyType = 'not_interested';
288
+ suggestedAction = 'Debrief and update status';
289
+ } else if (
290
+ bodyLower.includes('meeting') ||
291
+ bodyLower.includes('schedule') ||
292
+ bodyLower.includes('calendar') ||
293
+ bodyLower.includes('call')
294
+ ) {
295
+ replyType = 'meeting_request';
296
+ suggestedAction = 'Confirm meeting time';
297
+ } else if (
298
+ bodyLower.includes('interest') ||
299
+ bodyLower.includes('tell me more') ||
300
+ bodyLower.includes('sounds good') ||
301
+ bodyLower.includes('yes')
302
+ ) {
303
+ replyType = 'positive';
304
+ suggestedAction = 'Book meeting';
305
+ } else if (bodyLower.includes('?')) {
306
+ replyType = 'question';
307
+ suggestedAction = 'Answer question';
308
+ }
309
+
310
+ // Try to find company from sender
311
+ if (!companyName && email.from) {
312
+ const domain = email.from.split('@')[1];
313
+ if (domain) {
314
+ try {
315
+ const search = await api('GET', '/api/v1/companies', {
316
+ params: { search: domain, limit: 1, page: 1 },
317
+ });
318
+ if (search?.data?.[0]) companyName = search.data[0].name;
319
+ } catch {
320
+ /* ignore */
321
+ }
322
+ }
323
+ }
324
+
325
+ return {
326
+ from: email.from || email.sender,
327
+ company: companyName,
328
+ subject: email.subject,
329
+ replyType,
330
+ suggestedAction,
331
+ };
332
+ })
333
+ );
334
+
335
+ return md(formatInbox(enriched));
336
+ } catch (err) {
337
+ return mdError('Could not check inbox', err.message);
338
+ }
339
+ }
340
+ );
341
+ }