@kivimedia/kmhub 2.9.1 → 2.11.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.
- package/README.md +170 -170
- package/bin/kmhub.mjs +896 -896
- package/coach-book-output-guard.mjs +760 -760
- package/index.mjs +57 -57
- package/package.json +56 -56
- package/prompts/briefing.md +29 -29
- package/prompts/luxury.md +70 -70
- package/prompts/play.md +49 -49
- package/prompts/run.md +37 -36
- package/prompts/setup.md +33 -33
- package/prompts/vs-booked.md +46 -46
- package/prompts/what-can-you-do.md +40 -40
- package/prompts.mjs +110 -110
- package/read-only-tools.json +143 -142
- package/remote.mjs +929 -929
- package/tools/balloon-costing.mjs +80 -80
- package/tools/booking-equipment.mjs +110 -110
- package/tools/bridges.mjs +54 -54
- package/tools/briefing.mjs +91 -91
- package/tools/calendar.mjs +170 -170
- package/tools/capabilities.mjs +155 -155
- package/tools/catalog.mjs +288 -288
- package/tools/clubs.mjs +176 -176
- package/tools/coach.mjs +771 -771
- package/tools/compare.mjs +76 -76
- package/tools/core.mjs +244 -244
- package/tools/crm.mjs +209 -209
- package/tools/dubsado.mjs +137 -137
- package/tools/exports.mjs +128 -128
- package/tools/fact-review.mjs +125 -125
- package/tools/flows.mjs +261 -261
- package/tools/forms.mjs +158 -158
- package/tools/gols.mjs +144 -134
- package/tools/hr.mjs +162 -162
- package/tools/knowledge.mjs +129 -125
- package/tools/marketing.mjs +396 -396
- package/tools/meta.mjs +245 -245
- package/tools/military.mjs +244 -244
- package/tools/money.mjs +235 -197
- package/tools/outreach.mjs +238 -238
- package/tools/pending.mjs +122 -122
- package/tools/photos.mjs +140 -140
- package/tools/plays.mjs +244 -244
- package/tools/profile.mjs +118 -118
- package/tools/radar.mjs +173 -173
- package/tools/recurring-invoices.mjs +149 -149
- package/tools/reengage.mjs +434 -434
- package/tools/schedules.mjs +55 -55
- package/tools/setup.mjs +168 -168
- package/tools/sops-bridges.mjs +86 -86
- package/tools/sops.mjs +314 -314
- package/tools/sourcing.mjs +268 -268
- package/tools/strategy.mjs +146 -146
- package/tools/studio.mjs +132 -132
- package/tools/venueradar.mjs +151 -151
- package/tools/voice.mjs +137 -134
- package/tools.mjs +407 -407
package/tools/marketing.mjs
CHANGED
|
@@ -1,396 +1,396 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tool family: marketing - being found, and the list you already own.
|
|
3
|
-
*
|
|
4
|
-
* Workstream B1. Before this family a client's Claude could see their pipeline,
|
|
5
|
-
* their diary and their money, and had no idea KM Hub also ran their search
|
|
6
|
-
* visibility and their newsletter. Asked "does it do my newsletter" it answered
|
|
7
|
-
* from its tool list and understated the product by a whole department.
|
|
8
|
-
*
|
|
9
|
-
* km_list_seo_sites -> GET /marketing/seo/sites
|
|
10
|
-
* km_list_seo_posts -> GET /marketing/seo/posts
|
|
11
|
-
* km_get_seo_post -> GET /marketing/seo/posts/:id
|
|
12
|
-
* km_list_newsletters -> GET /marketing/newsletters
|
|
13
|
-
* km_get_newsletter -> GET /marketing/newsletters/:id
|
|
14
|
-
* km_newsletter_audience -> GET /marketing/audience
|
|
15
|
-
* km_list_broadcasts -> GET /marketing/broadcasts
|
|
16
|
-
* km_lists -> GET /marketing/lists
|
|
17
|
-
* km_import_list -> POST /marketing/lists/import (free)
|
|
18
|
-
*
|
|
19
|
-
* 🚨 NOTHING HERE SENDS. No tool in this family sends a newsletter, publishes a
|
|
20
|
-
* post, or changes a schedule: a send is irreversible against a list somebody
|
|
21
|
-
* spent years building. When a user asks to send, say plainly that it happens
|
|
22
|
-
* in KM Hub and do not look for another route to it.
|
|
23
|
-
*
|
|
24
|
-
* BUILDING a list is allowed (16-Sep-2026). "If I give you a CSV can you create
|
|
25
|
-
* lists for me inside kmhub" was being answered "no" from the tool list, and the
|
|
26
|
-
* answer is yes: km_import_list takes the file's rows, files each person as a
|
|
27
|
-
* client tagged with the list name, and saves the segment. It is 'free' class -
|
|
28
|
-
* internal, reversible, nobody outside sees it.
|
|
29
|
-
*
|
|
30
|
-
* These routes ship on the API side separately from this connector, so each tool
|
|
31
|
-
* degrades in plain words when the route is not there yet: an older KM Hub is not
|
|
32
|
-
* a broken KM Hub.
|
|
33
|
-
*
|
|
34
|
-
* The family contract this file follows is documented in ./README.md.
|
|
35
|
-
*/
|
|
36
|
-
import { z } from 'zod';
|
|
37
|
-
import { readFileSync } from 'node:fs';
|
|
38
|
-
|
|
39
|
-
export const FAMILY = 'marketing';
|
|
40
|
-
|
|
41
|
-
export const TOOLS = [
|
|
42
|
-
'km_list_seo_sites',
|
|
43
|
-
'km_list_seo_posts',
|
|
44
|
-
'km_get_seo_post',
|
|
45
|
-
'km_list_newsletters',
|
|
46
|
-
'km_get_newsletter',
|
|
47
|
-
'km_newsletter_audience',
|
|
48
|
-
'km_list_broadcasts',
|
|
49
|
-
'km_lists',
|
|
50
|
-
'km_import_list',
|
|
51
|
-
];
|
|
52
|
-
|
|
53
|
-
/** Rows per import call, matching the API's IMPORT_MAX_ROWS. Bigger files are chunked. */
|
|
54
|
-
const IMPORT_CHUNK = 500;
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* RFC 4180 CSV -> array of objects keyed by the header row. Quoted fields, doubled
|
|
58
|
-
* quotes, embedded newlines and a BOM are handled; the delimiter (',' ';' or tab)
|
|
59
|
-
* is sniffed from the header line. No dependency: the connector ships with none
|
|
60
|
-
* it does not need.
|
|
61
|
-
*/
|
|
62
|
-
export function parseCsv(input) {
|
|
63
|
-
const src = String(input || '').replace(/^/, '');
|
|
64
|
-
const firstLine = src.split(/\r?\n/, 1)[0] || '';
|
|
65
|
-
const delim = [',', ';', '\t']
|
|
66
|
-
.map((d) => [d, firstLine.split(d).length - 1])
|
|
67
|
-
.sort((a, b) => b[1] - a[1])[0][0];
|
|
68
|
-
const rows = [];
|
|
69
|
-
let row = [];
|
|
70
|
-
let field = '';
|
|
71
|
-
let quoted = false;
|
|
72
|
-
for (let i = 0; i < src.length; i += 1) {
|
|
73
|
-
const c = src[i];
|
|
74
|
-
if (quoted) {
|
|
75
|
-
if (c === '"') {
|
|
76
|
-
if (src[i + 1] === '"') { field += '"'; i += 1; } else quoted = false;
|
|
77
|
-
} else field += c;
|
|
78
|
-
continue;
|
|
79
|
-
}
|
|
80
|
-
if (c === '"') { quoted = true; continue; }
|
|
81
|
-
if (c === delim) { row.push(field); field = ''; continue; }
|
|
82
|
-
if (c === '\r') continue;
|
|
83
|
-
if (c === '\n') { row.push(field); rows.push(row); row = []; field = ''; continue; }
|
|
84
|
-
field += c;
|
|
85
|
-
}
|
|
86
|
-
if (field !== '' || row.length) { row.push(field); rows.push(row); }
|
|
87
|
-
const nonEmpty = rows.filter((r) => r.some((v) => String(v).trim() !== ''));
|
|
88
|
-
if (nonEmpty.length < 2) return { headers: nonEmpty[0] || [], rows: [] };
|
|
89
|
-
const headers = nonEmpty[0].map((h) => String(h).trim());
|
|
90
|
-
return {
|
|
91
|
-
headers,
|
|
92
|
-
rows: nonEmpty.slice(1).map((r) => Object.fromEntries(headers.map((h, i) => [h, String(r[i] ?? '').trim()]))),
|
|
93
|
-
};
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/** Map whatever the file calls its columns onto the fields the API takes. */
|
|
97
|
-
const COLUMN_ALIASES = {
|
|
98
|
-
email: ['email', 'e-mail', 'email address', 'emailaddress', 'mail', 'primary email', 'email_address'],
|
|
99
|
-
name: ['name', 'full name', 'fullname', 'contact', 'contact name', 'client', 'client name', 'customer', 'customer name'],
|
|
100
|
-
first_name: ['first name', 'first', 'firstname', 'first_name', 'given name'],
|
|
101
|
-
last_name: ['last name', 'last', 'lastname', 'last_name', 'surname', 'family name'],
|
|
102
|
-
phone: ['phone', 'phone number', 'mobile', 'cell', 'telephone', 'tel', 'phone_number'],
|
|
103
|
-
company: ['company', 'company name', 'organization', 'organisation', 'business', 'business name', 'company_name'],
|
|
104
|
-
};
|
|
105
|
-
|
|
106
|
-
export function mapColumns(headers) {
|
|
107
|
-
const lower = headers.map((h) => String(h).trim().toLowerCase());
|
|
108
|
-
const map = {};
|
|
109
|
-
for (const [field, aliases] of Object.entries(COLUMN_ALIASES)) {
|
|
110
|
-
const idx = lower.findIndex((h) => aliases.includes(h));
|
|
111
|
-
if (idx >= 0) map[field] = headers[idx];
|
|
112
|
-
}
|
|
113
|
-
// Exports rarely agree on a header. Anything with "email" in it still counts.
|
|
114
|
-
if (!map.email) {
|
|
115
|
-
const idx = lower.findIndex((h) => h.includes('email') || h.includes('e-mail'));
|
|
116
|
-
if (idx >= 0) map.email = headers[idx];
|
|
117
|
-
}
|
|
118
|
-
return map;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
function toApiRows(parsed) {
|
|
122
|
-
const map = mapColumns(parsed.headers);
|
|
123
|
-
if (!map.email) {
|
|
124
|
-
return { error: `No email column found. Columns seen: ${parsed.headers.join(', ') || '(none)'}. Tell me which one holds the address.`, rows: [] };
|
|
125
|
-
}
|
|
126
|
-
return {
|
|
127
|
-
rows: parsed.rows.map((r) => ({
|
|
128
|
-
email: r[map.email],
|
|
129
|
-
...(map.name ? { name: r[map.name] } : {}),
|
|
130
|
-
...(map.first_name ? { first_name: r[map.first_name] } : {}),
|
|
131
|
-
...(map.last_name ? { last_name: r[map.last_name] } : {}),
|
|
132
|
-
...(map.phone ? { phone: r[map.phone] } : {}),
|
|
133
|
-
...(map.company ? { company: r[map.company] } : {}),
|
|
134
|
-
})),
|
|
135
|
-
mapped: map,
|
|
136
|
-
};
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// 'content' is the profile a client installs when their work is publishing and
|
|
140
|
-
// list-building rather than pipeline chasing.
|
|
141
|
-
export const PROFILES = ['content', 'outreach'];
|
|
142
|
-
|
|
143
|
-
function routeMissing(r) {
|
|
144
|
-
return r.status === 404 || r.status === 405 || r.status === 501;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
const NOT_SUPPORTED =
|
|
148
|
-
'Your KM Hub does not expose the marketing surface to the terminal yet. That is not a fault: the workspace ' +
|
|
149
|
-
'is fine and every other tool works as normal. SEO and the newsletter are both there in the web app at ' +
|
|
150
|
-
'https://hub.kivimedia.co, and this connector will start reading them once your KM Hub is on a build that publishes them.';
|
|
151
|
-
|
|
152
|
-
export function register(server, call, { out, text, qs }) {
|
|
153
|
-
server.tool(
|
|
154
|
-
'km_list_seo_sites',
|
|
155
|
-
'The websites KM Hub is publishing search content to, and whether it is doing it automatically. ' +
|
|
156
|
-
'Call this when the user asks about SEO, being found on Google, showing up in AI search, their blog, or why ' +
|
|
157
|
-
'nothing has been published lately. It returns each connected site, its category, whether weekly autopilot is on, ' +
|
|
158
|
-
'whether autopilot is allowed to publish by itself, when it last published, and the last error if there was one. ' +
|
|
159
|
-
'An empty list is a real and important answer: no site connected means nothing is being published at all, and the ' +
|
|
160
|
-
'user almost certainly does not know that. Connecting a site and changing autopilot happen in KM Hub, not here. ' +
|
|
161
|
-
'Read only, sends nothing, changes nothing.',
|
|
162
|
-
{},
|
|
163
|
-
async () => {
|
|
164
|
-
const r = await call('GET', '/marketing/seo/sites');
|
|
165
|
-
if (routeMissing(r)) return text(NOT_SUPPORTED, true);
|
|
166
|
-
return out(r);
|
|
167
|
-
},
|
|
168
|
-
);
|
|
169
|
-
|
|
170
|
-
server.tool(
|
|
171
|
-
'km_list_seo_posts',
|
|
172
|
-
'The SEO content pipeline: what has been written, what is waiting, what is published and what is stuck. ' +
|
|
173
|
-
'Use it when the user asks what has been published, what is in the works, why a post has not gone out, or how ' +
|
|
174
|
-
'their content is doing. Returns the title, target keyword, silo, status, whether it is blocked and why, and the ' +
|
|
175
|
-
'published URL where there is one, plus a count by status so you can describe the shape of the pipeline in a sentence. ' +
|
|
176
|
-
'🚨 The article body is NOT included here, on purpose: post bodies are thousands of words and twenty of them would ' +
|
|
177
|
-
'drown the answer. Use km_get_seo_post for one specific post. Anything blocked is worth raising unprompted, because ' +
|
|
178
|
-
'a blocked post is silent work that has stopped. Read only.',
|
|
179
|
-
{
|
|
180
|
-
status: z
|
|
181
|
-
.enum(['draft', 'generating', 'ready', 'approved', 'publishing', 'published', 'failed', 'blocked'])
|
|
182
|
-
.optional()
|
|
183
|
-
.describe('Narrow to one stage. Leave it out for the whole pipeline, which is usually what you want first.'),
|
|
184
|
-
limit: z.number().int().min(1).max(100).optional().describe('How many posts, newest first. Default 25.'),
|
|
185
|
-
},
|
|
186
|
-
async ({ status, limit }) => {
|
|
187
|
-
const r = await call('GET', `/marketing/seo/posts${qs({ status, limit })}`);
|
|
188
|
-
if (routeMissing(r)) return text(NOT_SUPPORTED, true);
|
|
189
|
-
return out(r);
|
|
190
|
-
},
|
|
191
|
-
);
|
|
192
|
-
|
|
193
|
-
server.tool(
|
|
194
|
-
'km_get_seo_post',
|
|
195
|
-
'One SEO post in detail: its outline, its meta title and description, its quality scores, its target and secondary ' +
|
|
196
|
-
'keywords, and the first part of the body. Use it when the user asks about a specific post, wants to know whether ' +
|
|
197
|
-
'something is any good before it goes out, or asks why one is blocked. ' +
|
|
198
|
-
'The body comes back as a preview with the true character count alongside it, so you can say how long the piece is ' +
|
|
199
|
-
'without carrying all of it. Editing, approving and publishing all happen in KM Hub at https://hub.kivimedia.co: ' +
|
|
200
|
-
'say so rather than offering to do it. Read only.',
|
|
201
|
-
{
|
|
202
|
-
id: z.string().describe('The post id, as km_list_seo_posts returned it.'),
|
|
203
|
-
},
|
|
204
|
-
async ({ id }) => {
|
|
205
|
-
const r = await call('GET', `/marketing/seo/posts/${encodeURIComponent(String(id || '').trim())}`);
|
|
206
|
-
if (routeMissing(r)) return text(NOT_SUPPORTED, true);
|
|
207
|
-
return out(r);
|
|
208
|
-
},
|
|
209
|
-
);
|
|
210
|
-
|
|
211
|
-
server.tool(
|
|
212
|
-
'km_list_newsletters',
|
|
213
|
-
'The newsletter: every issue, with the numbers that actually matter. Use it when the user asks about their ' +
|
|
214
|
-
'newsletter, their list, their email marketing, whether anyone reads what they send, or what went out last month. ' +
|
|
215
|
-
'Each issue returns recipients, sent, delivered, opened, clicked, bounced and complained, plus open, click and ' +
|
|
216
|
-
'bounce rates already worked out against delivered rather than against sent, which is the comparison that is not ' +
|
|
217
|
-
'misleading. ' +
|
|
218
|
-
'🚨 A bounce rate creeping up matters more than an open rate going down and almost nobody looks at it: rising ' +
|
|
219
|
-
'bounces or complaints put the sending domain at risk, which quietly costs every future send. Raise it if you see it. ' +
|
|
220
|
-
'Nothing here can write, schedule or send an issue. Read only.',
|
|
221
|
-
{
|
|
222
|
-
status: z
|
|
223
|
-
.enum(['draft', 'scheduled', 'sending', 'sent', 'failed'])
|
|
224
|
-
.optional()
|
|
225
|
-
.describe('Narrow to one state. "scheduled" answers "what is going out next", "sent" answers "how did it do".'),
|
|
226
|
-
limit: z.number().int().min(1).max(100).optional().describe('How many issues, newest first. Default 20.'),
|
|
227
|
-
},
|
|
228
|
-
async ({ status, limit }) => {
|
|
229
|
-
const r = await call('GET', `/marketing/newsletters${qs({ status, limit })}`);
|
|
230
|
-
if (routeMissing(r)) return text(NOT_SUPPORTED, true);
|
|
231
|
-
return out(r);
|
|
232
|
-
},
|
|
233
|
-
);
|
|
234
|
-
|
|
235
|
-
server.tool(
|
|
236
|
-
'km_get_newsletter',
|
|
237
|
-
'One newsletter issue in full: subject, preheader, sender name, send timezone, its A/B test if it ran one, and the ' +
|
|
238
|
-
'complete delivery numbers. Use it when the user asks about a specific issue or wants to know why one performed ' +
|
|
239
|
-
'differently from another. The rendered HTML is not returned, because a built email is enormous and unreadable as ' +
|
|
240
|
-
'text; the subject and preheader are what actually decided the open rate. Read only.',
|
|
241
|
-
{
|
|
242
|
-
id: z.string().describe('The newsletter id, as km_list_newsletters returned it.'),
|
|
243
|
-
},
|
|
244
|
-
async ({ id }) => {
|
|
245
|
-
const r = await call('GET', `/marketing/newsletters/${encodeURIComponent(String(id || '').trim())}`);
|
|
246
|
-
if (routeMissing(r)) return text(NOT_SUPPORTED, true);
|
|
247
|
-
return out(r);
|
|
248
|
-
},
|
|
249
|
-
);
|
|
250
|
-
|
|
251
|
-
server.tool(
|
|
252
|
-
'km_newsletter_audience',
|
|
253
|
-
'How big the newsletter list is and what state it is in: subscribed, unsubscribed, unconfirmed and so on. ' +
|
|
254
|
-
'Use it when the user asks how many people they can reach, whether the list is growing, or before advising ' +
|
|
255
|
-
'anything about sending. ' +
|
|
256
|
-
'🚨 It returns COUNTS, never the people. Individual subscribers are deliberately not available to the terminal: a ' +
|
|
257
|
-
'mailing list is the most sensitive asset in the workspace and there is no reason a command line needs to pull it ' +
|
|
258
|
-
'down. Do not look for another route to the addresses. ' +
|
|
259
|
-
'A large unconfirmed count is worth raising: those are people who signed up and never completed it, so they are ' +
|
|
260
|
-
'not being reached at all. Read only.',
|
|
261
|
-
{},
|
|
262
|
-
async () => {
|
|
263
|
-
const r = await call('GET', '/marketing/audience');
|
|
264
|
-
if (routeMissing(r)) return text(NOT_SUPPORTED, true);
|
|
265
|
-
return out(r);
|
|
266
|
-
},
|
|
267
|
-
);
|
|
268
|
-
|
|
269
|
-
server.tool(
|
|
270
|
-
'km_list_broadcasts',
|
|
271
|
-
'One-off sends to the list, as distinct from the regular newsletter: an announcement, a date release, a last-minute ' +
|
|
272
|
-
'offer. Returns the subject, the audience it went to, its status and how many were sent or failed. ' +
|
|
273
|
-
'Use it when the user asks what they have sent recently, or when you are about to suggest emailing the list and ' +
|
|
274
|
-
'need to know whether they already have. Sending one happens in KM Hub at https://hub.kivimedia.co. Read only.',
|
|
275
|
-
{
|
|
276
|
-
limit: z.number().int().min(1).max(100).optional().describe('How many broadcasts, newest first. Default 20.'),
|
|
277
|
-
},
|
|
278
|
-
async ({ limit }) => {
|
|
279
|
-
const r = await call('GET', `/marketing/broadcasts${qs({ limit })}`);
|
|
280
|
-
if (routeMissing(r)) return text(NOT_SUPPORTED, true);
|
|
281
|
-
return out(r);
|
|
282
|
-
},
|
|
283
|
-
);
|
|
284
|
-
|
|
285
|
-
server.tool(
|
|
286
|
-
'km_lists',
|
|
287
|
-
'The saved lists: every segment in the workspace with its name, the tag it is built on and a live member count. ' +
|
|
288
|
-
'Use it when the user asks what lists they have, how big one is, or before importing so you can tell whether ' +
|
|
289
|
-
'the list already exists (an import into an existing name GROWS it rather than making a second one). ' +
|
|
290
|
-
'Returns list names and sizes, never the people on them. Read only.',
|
|
291
|
-
{},
|
|
292
|
-
async () => {
|
|
293
|
-
const r = await call('GET', '/marketing/lists');
|
|
294
|
-
if (routeMissing(r)) return text(NOT_SUPPORTED, true);
|
|
295
|
-
return out(r);
|
|
296
|
-
},
|
|
297
|
-
);
|
|
298
|
-
|
|
299
|
-
server.tool(
|
|
300
|
-
'km_import_list',
|
|
301
|
-
'Build a list in KM Hub from a CSV or from rows: YES, this is the tool for "here is a CSV, make me a list". ' +
|
|
302
|
-
'Each person is filed as a client (found by email if they already exist, created if not), tagged with the list ' +
|
|
303
|
-
'name, and a saved segment with that name is created so the list is ready to pick in Compose & Send, broadcasts ' +
|
|
304
|
-
'and re-engagement. Re-running with the same list name adds to it and never duplicates a contact. ' +
|
|
305
|
-
'Give it EITHER csv_text (the file contents, header row included - email / name / first name / last name / ' +
|
|
306
|
-
'phone / company are picked up by their usual column names) OR rows (already structured) OR csv_path (a file on ' +
|
|
307
|
-
'the machine this connector runs on - local installs only, not the hosted connector). Files over 500 rows are ' +
|
|
308
|
-
'sent in chunks automatically. ' +
|
|
309
|
-
'Report back what it returns: created, tagged_existing, already_on_list and the skipped counts - a row with no ' +
|
|
310
|
-
'usable email is skipped and listed, not silently lost. Nothing is SENT to anyone: this builds the list only.',
|
|
311
|
-
{
|
|
312
|
-
list_name: z.string().min(1).max(120).describe('The list name. Becomes the segment name and the tag on every contact. Reuse a name to grow that list.'),
|
|
313
|
-
csv_text: z.string().optional().describe('The raw CSV contents including the header row. Preferred when the user pasted or attached the file.'),
|
|
314
|
-
csv_path: z.string().optional().describe('Absolute path to a CSV on the machine running this connector. Local installs only.'),
|
|
315
|
-
rows: z
|
|
316
|
-
.array(
|
|
317
|
-
z.object({
|
|
318
|
-
email: z.string(),
|
|
319
|
-
name: z.string().optional(),
|
|
320
|
-
first_name: z.string().optional(),
|
|
321
|
-
last_name: z.string().optional(),
|
|
322
|
-
phone: z.string().optional(),
|
|
323
|
-
company: z.string().optional(),
|
|
324
|
-
}),
|
|
325
|
-
)
|
|
326
|
-
.optional()
|
|
327
|
-
.describe('Structured rows, if you already have them. email is required per row.'),
|
|
328
|
-
description: z.string().max(500).optional().describe('Shown on the segment card in KM Hub. Defaults to "Imported list (date)".'),
|
|
329
|
-
source: z.string().max(60).optional().describe('Stamped on created contacts as their source. Defaults to api_import; use e.g. "brevo_export" when you know where the file came from.'),
|
|
330
|
-
},
|
|
331
|
-
async ({ list_name, csv_text, csv_path, rows, description, source }) => {
|
|
332
|
-
let apiRows = Array.isArray(rows) && rows.length ? rows : null;
|
|
333
|
-
let mapped = null;
|
|
334
|
-
if (!apiRows) {
|
|
335
|
-
let raw = csv_text;
|
|
336
|
-
if (!raw && csv_path) {
|
|
337
|
-
try {
|
|
338
|
-
raw = readFileSync(csv_path, 'utf8');
|
|
339
|
-
} catch (e) {
|
|
340
|
-
return text(
|
|
341
|
-
`I could not read ${csv_path} from here (${e.code || e.message}). On the hosted connector the file is on your machine, not mine: paste the file contents as csv_text instead.`,
|
|
342
|
-
true,
|
|
343
|
-
);
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
if (!raw) return text('Give me the list as csv_text (the file contents), rows, or a csv_path on a local install.', true);
|
|
347
|
-
const converted = toApiRows(parseCsv(raw));
|
|
348
|
-
if (converted.error) return text(converted.error, true);
|
|
349
|
-
apiRows = converted.rows;
|
|
350
|
-
mapped = converted.mapped;
|
|
351
|
-
}
|
|
352
|
-
if (!apiRows.length) return text('The file parsed but had no data rows under the header, so there is nothing to import.', true);
|
|
353
|
-
|
|
354
|
-
const totals = { received: 0, created: 0, tagged_existing: 0, already_on_list: 0, invalid_email: 0, duplicate_in_file: 0, tag_failed: 0 };
|
|
355
|
-
const invalidExamples = [];
|
|
356
|
-
const problems = [];
|
|
357
|
-
let list = null;
|
|
358
|
-
for (let i = 0; i < apiRows.length; i += IMPORT_CHUNK) {
|
|
359
|
-
const chunk = apiRows.slice(i, i + IMPORT_CHUNK);
|
|
360
|
-
const chunkNo = i / IMPORT_CHUNK + 1;
|
|
361
|
-
const r = await call('POST', '/marketing/lists/import', { list_name, rows: chunk, description, source });
|
|
362
|
-
if (routeMissing(r)) return text(NOT_SUPPORTED, true);
|
|
363
|
-
if (r.status === 402) return out(r);
|
|
364
|
-
const d = r.data && typeof r.data === 'object' ? r.data : {};
|
|
365
|
-
if (!r.ok && r.status !== 207) {
|
|
366
|
-
problems.push(`chunk ${chunkNo} (rows ${i + 1}-${i + chunk.length}): ${d.message || JSON.stringify(d).slice(0, 300)}`);
|
|
367
|
-
continue;
|
|
368
|
-
}
|
|
369
|
-
totals.received += Number(d.received) || 0;
|
|
370
|
-
totals.created += Number(d.created) || 0;
|
|
371
|
-
totals.tagged_existing += Number(d.tagged_existing) || 0;
|
|
372
|
-
totals.already_on_list += Number(d.already_on_list) || 0;
|
|
373
|
-
for (const k of ['invalid_email', 'duplicate_in_file', 'tag_failed']) totals[k] += Number(d.skipped?.[k]) || 0;
|
|
374
|
-
for (const ex of d.invalid_examples || []) if (invalidExamples.length < 10) invalidExamples.push(ex);
|
|
375
|
-
if (d.insert_error) problems.push(`chunk ${chunkNo}: ${d.note_on_error || d.insert_error}`);
|
|
376
|
-
if (d.segment_error) problems.push(d.segment_error);
|
|
377
|
-
if (d.list) list = d.list;
|
|
378
|
-
}
|
|
379
|
-
const ok = problems.length === 0 && Boolean(list && list.id);
|
|
380
|
-
const summary = {
|
|
381
|
-
ok,
|
|
382
|
-
list,
|
|
383
|
-
chunks: Math.ceil(apiRows.length / IMPORT_CHUNK),
|
|
384
|
-
...(mapped ? { columns_used: mapped } : {}),
|
|
385
|
-
...totals,
|
|
386
|
-
invalid_examples: invalidExamples,
|
|
387
|
-
...(problems.length ? { problems } : {}),
|
|
388
|
-
where: 'https://hub.kivimedia.co/?page=email-segments',
|
|
389
|
-
note: ok
|
|
390
|
-
? `"${list.name}" now has ${list.members ?? '?'} members and is ready to pick as an audience in KM Hub. Nothing was sent.`
|
|
391
|
-
: 'The import did not fully land - see problems. Re-sending the same file is safe: existing contacts are tagged, not duplicated.',
|
|
392
|
-
};
|
|
393
|
-
return { content: [{ type: 'text', text: JSON.stringify(summary, null, 2) }], isError: !ok };
|
|
394
|
-
},
|
|
395
|
-
);
|
|
396
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Tool family: marketing - being found, and the list you already own.
|
|
3
|
+
*
|
|
4
|
+
* Workstream B1. Before this family a client's Claude could see their pipeline,
|
|
5
|
+
* their diary and their money, and had no idea KM Hub also ran their search
|
|
6
|
+
* visibility and their newsletter. Asked "does it do my newsletter" it answered
|
|
7
|
+
* from its tool list and understated the product by a whole department.
|
|
8
|
+
*
|
|
9
|
+
* km_list_seo_sites -> GET /marketing/seo/sites
|
|
10
|
+
* km_list_seo_posts -> GET /marketing/seo/posts
|
|
11
|
+
* km_get_seo_post -> GET /marketing/seo/posts/:id
|
|
12
|
+
* km_list_newsletters -> GET /marketing/newsletters
|
|
13
|
+
* km_get_newsletter -> GET /marketing/newsletters/:id
|
|
14
|
+
* km_newsletter_audience -> GET /marketing/audience
|
|
15
|
+
* km_list_broadcasts -> GET /marketing/broadcasts
|
|
16
|
+
* km_lists -> GET /marketing/lists
|
|
17
|
+
* km_import_list -> POST /marketing/lists/import (free)
|
|
18
|
+
*
|
|
19
|
+
* 🚨 NOTHING HERE SENDS. No tool in this family sends a newsletter, publishes a
|
|
20
|
+
* post, or changes a schedule: a send is irreversible against a list somebody
|
|
21
|
+
* spent years building. When a user asks to send, say plainly that it happens
|
|
22
|
+
* in KM Hub and do not look for another route to it.
|
|
23
|
+
*
|
|
24
|
+
* BUILDING a list is allowed (16-Sep-2026). "If I give you a CSV can you create
|
|
25
|
+
* lists for me inside kmhub" was being answered "no" from the tool list, and the
|
|
26
|
+
* answer is yes: km_import_list takes the file's rows, files each person as a
|
|
27
|
+
* client tagged with the list name, and saves the segment. It is 'free' class -
|
|
28
|
+
* internal, reversible, nobody outside sees it.
|
|
29
|
+
*
|
|
30
|
+
* These routes ship on the API side separately from this connector, so each tool
|
|
31
|
+
* degrades in plain words when the route is not there yet: an older KM Hub is not
|
|
32
|
+
* a broken KM Hub.
|
|
33
|
+
*
|
|
34
|
+
* The family contract this file follows is documented in ./README.md.
|
|
35
|
+
*/
|
|
36
|
+
import { z } from 'zod';
|
|
37
|
+
import { readFileSync } from 'node:fs';
|
|
38
|
+
|
|
39
|
+
export const FAMILY = 'marketing';
|
|
40
|
+
|
|
41
|
+
export const TOOLS = [
|
|
42
|
+
'km_list_seo_sites',
|
|
43
|
+
'km_list_seo_posts',
|
|
44
|
+
'km_get_seo_post',
|
|
45
|
+
'km_list_newsletters',
|
|
46
|
+
'km_get_newsletter',
|
|
47
|
+
'km_newsletter_audience',
|
|
48
|
+
'km_list_broadcasts',
|
|
49
|
+
'km_lists',
|
|
50
|
+
'km_import_list',
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
/** Rows per import call, matching the API's IMPORT_MAX_ROWS. Bigger files are chunked. */
|
|
54
|
+
const IMPORT_CHUNK = 500;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* RFC 4180 CSV -> array of objects keyed by the header row. Quoted fields, doubled
|
|
58
|
+
* quotes, embedded newlines and a BOM are handled; the delimiter (',' ';' or tab)
|
|
59
|
+
* is sniffed from the header line. No dependency: the connector ships with none
|
|
60
|
+
* it does not need.
|
|
61
|
+
*/
|
|
62
|
+
export function parseCsv(input) {
|
|
63
|
+
const src = String(input || '').replace(/^/, '');
|
|
64
|
+
const firstLine = src.split(/\r?\n/, 1)[0] || '';
|
|
65
|
+
const delim = [',', ';', '\t']
|
|
66
|
+
.map((d) => [d, firstLine.split(d).length - 1])
|
|
67
|
+
.sort((a, b) => b[1] - a[1])[0][0];
|
|
68
|
+
const rows = [];
|
|
69
|
+
let row = [];
|
|
70
|
+
let field = '';
|
|
71
|
+
let quoted = false;
|
|
72
|
+
for (let i = 0; i < src.length; i += 1) {
|
|
73
|
+
const c = src[i];
|
|
74
|
+
if (quoted) {
|
|
75
|
+
if (c === '"') {
|
|
76
|
+
if (src[i + 1] === '"') { field += '"'; i += 1; } else quoted = false;
|
|
77
|
+
} else field += c;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (c === '"') { quoted = true; continue; }
|
|
81
|
+
if (c === delim) { row.push(field); field = ''; continue; }
|
|
82
|
+
if (c === '\r') continue;
|
|
83
|
+
if (c === '\n') { row.push(field); rows.push(row); row = []; field = ''; continue; }
|
|
84
|
+
field += c;
|
|
85
|
+
}
|
|
86
|
+
if (field !== '' || row.length) { row.push(field); rows.push(row); }
|
|
87
|
+
const nonEmpty = rows.filter((r) => r.some((v) => String(v).trim() !== ''));
|
|
88
|
+
if (nonEmpty.length < 2) return { headers: nonEmpty[0] || [], rows: [] };
|
|
89
|
+
const headers = nonEmpty[0].map((h) => String(h).trim());
|
|
90
|
+
return {
|
|
91
|
+
headers,
|
|
92
|
+
rows: nonEmpty.slice(1).map((r) => Object.fromEntries(headers.map((h, i) => [h, String(r[i] ?? '').trim()]))),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Map whatever the file calls its columns onto the fields the API takes. */
|
|
97
|
+
const COLUMN_ALIASES = {
|
|
98
|
+
email: ['email', 'e-mail', 'email address', 'emailaddress', 'mail', 'primary email', 'email_address'],
|
|
99
|
+
name: ['name', 'full name', 'fullname', 'contact', 'contact name', 'client', 'client name', 'customer', 'customer name'],
|
|
100
|
+
first_name: ['first name', 'first', 'firstname', 'first_name', 'given name'],
|
|
101
|
+
last_name: ['last name', 'last', 'lastname', 'last_name', 'surname', 'family name'],
|
|
102
|
+
phone: ['phone', 'phone number', 'mobile', 'cell', 'telephone', 'tel', 'phone_number'],
|
|
103
|
+
company: ['company', 'company name', 'organization', 'organisation', 'business', 'business name', 'company_name'],
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export function mapColumns(headers) {
|
|
107
|
+
const lower = headers.map((h) => String(h).trim().toLowerCase());
|
|
108
|
+
const map = {};
|
|
109
|
+
for (const [field, aliases] of Object.entries(COLUMN_ALIASES)) {
|
|
110
|
+
const idx = lower.findIndex((h) => aliases.includes(h));
|
|
111
|
+
if (idx >= 0) map[field] = headers[idx];
|
|
112
|
+
}
|
|
113
|
+
// Exports rarely agree on a header. Anything with "email" in it still counts.
|
|
114
|
+
if (!map.email) {
|
|
115
|
+
const idx = lower.findIndex((h) => h.includes('email') || h.includes('e-mail'));
|
|
116
|
+
if (idx >= 0) map.email = headers[idx];
|
|
117
|
+
}
|
|
118
|
+
return map;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function toApiRows(parsed) {
|
|
122
|
+
const map = mapColumns(parsed.headers);
|
|
123
|
+
if (!map.email) {
|
|
124
|
+
return { error: `No email column found. Columns seen: ${parsed.headers.join(', ') || '(none)'}. Tell me which one holds the address.`, rows: [] };
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
rows: parsed.rows.map((r) => ({
|
|
128
|
+
email: r[map.email],
|
|
129
|
+
...(map.name ? { name: r[map.name] } : {}),
|
|
130
|
+
...(map.first_name ? { first_name: r[map.first_name] } : {}),
|
|
131
|
+
...(map.last_name ? { last_name: r[map.last_name] } : {}),
|
|
132
|
+
...(map.phone ? { phone: r[map.phone] } : {}),
|
|
133
|
+
...(map.company ? { company: r[map.company] } : {}),
|
|
134
|
+
})),
|
|
135
|
+
mapped: map,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// 'content' is the profile a client installs when their work is publishing and
|
|
140
|
+
// list-building rather than pipeline chasing.
|
|
141
|
+
export const PROFILES = ['content', 'outreach'];
|
|
142
|
+
|
|
143
|
+
function routeMissing(r) {
|
|
144
|
+
return r.status === 404 || r.status === 405 || r.status === 501;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const NOT_SUPPORTED =
|
|
148
|
+
'Your KM Hub does not expose the marketing surface to the terminal yet. That is not a fault: the workspace ' +
|
|
149
|
+
'is fine and every other tool works as normal. SEO and the newsletter are both there in the web app at ' +
|
|
150
|
+
'https://hub.kivimedia.co, and this connector will start reading them once your KM Hub is on a build that publishes them.';
|
|
151
|
+
|
|
152
|
+
export function register(server, call, { out, text, qs }) {
|
|
153
|
+
server.tool(
|
|
154
|
+
'km_list_seo_sites',
|
|
155
|
+
'The websites KM Hub is publishing search content to, and whether it is doing it automatically. ' +
|
|
156
|
+
'Call this when the user asks about SEO, being found on Google, showing up in AI search, their blog, or why ' +
|
|
157
|
+
'nothing has been published lately. It returns each connected site, its category, whether weekly autopilot is on, ' +
|
|
158
|
+
'whether autopilot is allowed to publish by itself, when it last published, and the last error if there was one. ' +
|
|
159
|
+
'An empty list is a real and important answer: no site connected means nothing is being published at all, and the ' +
|
|
160
|
+
'user almost certainly does not know that. Connecting a site and changing autopilot happen in KM Hub, not here. ' +
|
|
161
|
+
'Read only, sends nothing, changes nothing.',
|
|
162
|
+
{},
|
|
163
|
+
async () => {
|
|
164
|
+
const r = await call('GET', '/marketing/seo/sites');
|
|
165
|
+
if (routeMissing(r)) return text(NOT_SUPPORTED, true);
|
|
166
|
+
return out(r);
|
|
167
|
+
},
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
server.tool(
|
|
171
|
+
'km_list_seo_posts',
|
|
172
|
+
'The SEO content pipeline: what has been written, what is waiting, what is published and what is stuck. ' +
|
|
173
|
+
'Use it when the user asks what has been published, what is in the works, why a post has not gone out, or how ' +
|
|
174
|
+
'their content is doing. Returns the title, target keyword, silo, status, whether it is blocked and why, and the ' +
|
|
175
|
+
'published URL where there is one, plus a count by status so you can describe the shape of the pipeline in a sentence. ' +
|
|
176
|
+
'🚨 The article body is NOT included here, on purpose: post bodies are thousands of words and twenty of them would ' +
|
|
177
|
+
'drown the answer. Use km_get_seo_post for one specific post. Anything blocked is worth raising unprompted, because ' +
|
|
178
|
+
'a blocked post is silent work that has stopped. Read only.',
|
|
179
|
+
{
|
|
180
|
+
status: z
|
|
181
|
+
.enum(['draft', 'generating', 'ready', 'approved', 'publishing', 'published', 'failed', 'blocked'])
|
|
182
|
+
.optional()
|
|
183
|
+
.describe('Narrow to one stage. Leave it out for the whole pipeline, which is usually what you want first.'),
|
|
184
|
+
limit: z.number().int().min(1).max(100).optional().describe('How many posts, newest first. Default 25.'),
|
|
185
|
+
},
|
|
186
|
+
async ({ status, limit }) => {
|
|
187
|
+
const r = await call('GET', `/marketing/seo/posts${qs({ status, limit })}`);
|
|
188
|
+
if (routeMissing(r)) return text(NOT_SUPPORTED, true);
|
|
189
|
+
return out(r);
|
|
190
|
+
},
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
server.tool(
|
|
194
|
+
'km_get_seo_post',
|
|
195
|
+
'One SEO post in detail: its outline, its meta title and description, its quality scores, its target and secondary ' +
|
|
196
|
+
'keywords, and the first part of the body. Use it when the user asks about a specific post, wants to know whether ' +
|
|
197
|
+
'something is any good before it goes out, or asks why one is blocked. ' +
|
|
198
|
+
'The body comes back as a preview with the true character count alongside it, so you can say how long the piece is ' +
|
|
199
|
+
'without carrying all of it. Editing, approving and publishing all happen in KM Hub at https://hub.kivimedia.co: ' +
|
|
200
|
+
'say so rather than offering to do it. Read only.',
|
|
201
|
+
{
|
|
202
|
+
id: z.string().describe('The post id, as km_list_seo_posts returned it.'),
|
|
203
|
+
},
|
|
204
|
+
async ({ id }) => {
|
|
205
|
+
const r = await call('GET', `/marketing/seo/posts/${encodeURIComponent(String(id || '').trim())}`);
|
|
206
|
+
if (routeMissing(r)) return text(NOT_SUPPORTED, true);
|
|
207
|
+
return out(r);
|
|
208
|
+
},
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
server.tool(
|
|
212
|
+
'km_list_newsletters',
|
|
213
|
+
'The newsletter: every issue, with the numbers that actually matter. Use it when the user asks about their ' +
|
|
214
|
+
'newsletter, their list, their email marketing, whether anyone reads what they send, or what went out last month. ' +
|
|
215
|
+
'Each issue returns recipients, sent, delivered, opened, clicked, bounced and complained, plus open, click and ' +
|
|
216
|
+
'bounce rates already worked out against delivered rather than against sent, which is the comparison that is not ' +
|
|
217
|
+
'misleading. ' +
|
|
218
|
+
'🚨 A bounce rate creeping up matters more than an open rate going down and almost nobody looks at it: rising ' +
|
|
219
|
+
'bounces or complaints put the sending domain at risk, which quietly costs every future send. Raise it if you see it. ' +
|
|
220
|
+
'Nothing here can write, schedule or send an issue. Read only.',
|
|
221
|
+
{
|
|
222
|
+
status: z
|
|
223
|
+
.enum(['draft', 'scheduled', 'sending', 'sent', 'failed'])
|
|
224
|
+
.optional()
|
|
225
|
+
.describe('Narrow to one state. "scheduled" answers "what is going out next", "sent" answers "how did it do".'),
|
|
226
|
+
limit: z.number().int().min(1).max(100).optional().describe('How many issues, newest first. Default 20.'),
|
|
227
|
+
},
|
|
228
|
+
async ({ status, limit }) => {
|
|
229
|
+
const r = await call('GET', `/marketing/newsletters${qs({ status, limit })}`);
|
|
230
|
+
if (routeMissing(r)) return text(NOT_SUPPORTED, true);
|
|
231
|
+
return out(r);
|
|
232
|
+
},
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
server.tool(
|
|
236
|
+
'km_get_newsletter',
|
|
237
|
+
'One newsletter issue in full: subject, preheader, sender name, send timezone, its A/B test if it ran one, and the ' +
|
|
238
|
+
'complete delivery numbers. Use it when the user asks about a specific issue or wants to know why one performed ' +
|
|
239
|
+
'differently from another. The rendered HTML is not returned, because a built email is enormous and unreadable as ' +
|
|
240
|
+
'text; the subject and preheader are what actually decided the open rate. Read only.',
|
|
241
|
+
{
|
|
242
|
+
id: z.string().describe('The newsletter id, as km_list_newsletters returned it.'),
|
|
243
|
+
},
|
|
244
|
+
async ({ id }) => {
|
|
245
|
+
const r = await call('GET', `/marketing/newsletters/${encodeURIComponent(String(id || '').trim())}`);
|
|
246
|
+
if (routeMissing(r)) return text(NOT_SUPPORTED, true);
|
|
247
|
+
return out(r);
|
|
248
|
+
},
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
server.tool(
|
|
252
|
+
'km_newsletter_audience',
|
|
253
|
+
'How big the newsletter list is and what state it is in: subscribed, unsubscribed, unconfirmed and so on. ' +
|
|
254
|
+
'Use it when the user asks how many people they can reach, whether the list is growing, or before advising ' +
|
|
255
|
+
'anything about sending. ' +
|
|
256
|
+
'🚨 It returns COUNTS, never the people. Individual subscribers are deliberately not available to the terminal: a ' +
|
|
257
|
+
'mailing list is the most sensitive asset in the workspace and there is no reason a command line needs to pull it ' +
|
|
258
|
+
'down. Do not look for another route to the addresses. ' +
|
|
259
|
+
'A large unconfirmed count is worth raising: those are people who signed up and never completed it, so they are ' +
|
|
260
|
+
'not being reached at all. Read only.',
|
|
261
|
+
{},
|
|
262
|
+
async () => {
|
|
263
|
+
const r = await call('GET', '/marketing/audience');
|
|
264
|
+
if (routeMissing(r)) return text(NOT_SUPPORTED, true);
|
|
265
|
+
return out(r);
|
|
266
|
+
},
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
server.tool(
|
|
270
|
+
'km_list_broadcasts',
|
|
271
|
+
'One-off sends to the list, as distinct from the regular newsletter: an announcement, a date release, a last-minute ' +
|
|
272
|
+
'offer. Returns the subject, the audience it went to, its status and how many were sent or failed. ' +
|
|
273
|
+
'Use it when the user asks what they have sent recently, or when you are about to suggest emailing the list and ' +
|
|
274
|
+
'need to know whether they already have. Sending one happens in KM Hub at https://hub.kivimedia.co. Read only.',
|
|
275
|
+
{
|
|
276
|
+
limit: z.number().int().min(1).max(100).optional().describe('How many broadcasts, newest first. Default 20.'),
|
|
277
|
+
},
|
|
278
|
+
async ({ limit }) => {
|
|
279
|
+
const r = await call('GET', `/marketing/broadcasts${qs({ limit })}`);
|
|
280
|
+
if (routeMissing(r)) return text(NOT_SUPPORTED, true);
|
|
281
|
+
return out(r);
|
|
282
|
+
},
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
server.tool(
|
|
286
|
+
'km_lists',
|
|
287
|
+
'The saved lists: every segment in the workspace with its name, the tag it is built on and a live member count. ' +
|
|
288
|
+
'Use it when the user asks what lists they have, how big one is, or before importing so you can tell whether ' +
|
|
289
|
+
'the list already exists (an import into an existing name GROWS it rather than making a second one). ' +
|
|
290
|
+
'Returns list names and sizes, never the people on them. Read only.',
|
|
291
|
+
{},
|
|
292
|
+
async () => {
|
|
293
|
+
const r = await call('GET', '/marketing/lists');
|
|
294
|
+
if (routeMissing(r)) return text(NOT_SUPPORTED, true);
|
|
295
|
+
return out(r);
|
|
296
|
+
},
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
server.tool(
|
|
300
|
+
'km_import_list',
|
|
301
|
+
'Build a list in KM Hub from a CSV or from rows: YES, this is the tool for "here is a CSV, make me a list". ' +
|
|
302
|
+
'Each person is filed as a client (found by email if they already exist, created if not), tagged with the list ' +
|
|
303
|
+
'name, and a saved segment with that name is created so the list is ready to pick in Compose & Send, broadcasts ' +
|
|
304
|
+
'and re-engagement. Re-running with the same list name adds to it and never duplicates a contact. ' +
|
|
305
|
+
'Give it EITHER csv_text (the file contents, header row included - email / name / first name / last name / ' +
|
|
306
|
+
'phone / company are picked up by their usual column names) OR rows (already structured) OR csv_path (a file on ' +
|
|
307
|
+
'the machine this connector runs on - local installs only, not the hosted connector). Files over 500 rows are ' +
|
|
308
|
+
'sent in chunks automatically. ' +
|
|
309
|
+
'Report back what it returns: created, tagged_existing, already_on_list and the skipped counts - a row with no ' +
|
|
310
|
+
'usable email is skipped and listed, not silently lost. Nothing is SENT to anyone: this builds the list only.',
|
|
311
|
+
{
|
|
312
|
+
list_name: z.string().min(1).max(120).describe('The list name. Becomes the segment name and the tag on every contact. Reuse a name to grow that list.'),
|
|
313
|
+
csv_text: z.string().optional().describe('The raw CSV contents including the header row. Preferred when the user pasted or attached the file.'),
|
|
314
|
+
csv_path: z.string().optional().describe('Absolute path to a CSV on the machine running this connector. Local installs only.'),
|
|
315
|
+
rows: z
|
|
316
|
+
.array(
|
|
317
|
+
z.object({
|
|
318
|
+
email: z.string(),
|
|
319
|
+
name: z.string().optional(),
|
|
320
|
+
first_name: z.string().optional(),
|
|
321
|
+
last_name: z.string().optional(),
|
|
322
|
+
phone: z.string().optional(),
|
|
323
|
+
company: z.string().optional(),
|
|
324
|
+
}),
|
|
325
|
+
)
|
|
326
|
+
.optional()
|
|
327
|
+
.describe('Structured rows, if you already have them. email is required per row.'),
|
|
328
|
+
description: z.string().max(500).optional().describe('Shown on the segment card in KM Hub. Defaults to "Imported list (date)".'),
|
|
329
|
+
source: z.string().max(60).optional().describe('Stamped on created contacts as their source. Defaults to api_import; use e.g. "brevo_export" when you know where the file came from.'),
|
|
330
|
+
},
|
|
331
|
+
async ({ list_name, csv_text, csv_path, rows, description, source }) => {
|
|
332
|
+
let apiRows = Array.isArray(rows) && rows.length ? rows : null;
|
|
333
|
+
let mapped = null;
|
|
334
|
+
if (!apiRows) {
|
|
335
|
+
let raw = csv_text;
|
|
336
|
+
if (!raw && csv_path) {
|
|
337
|
+
try {
|
|
338
|
+
raw = readFileSync(csv_path, 'utf8');
|
|
339
|
+
} catch (e) {
|
|
340
|
+
return text(
|
|
341
|
+
`I could not read ${csv_path} from here (${e.code || e.message}). On the hosted connector the file is on your machine, not mine: paste the file contents as csv_text instead.`,
|
|
342
|
+
true,
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
if (!raw) return text('Give me the list as csv_text (the file contents), rows, or a csv_path on a local install.', true);
|
|
347
|
+
const converted = toApiRows(parseCsv(raw));
|
|
348
|
+
if (converted.error) return text(converted.error, true);
|
|
349
|
+
apiRows = converted.rows;
|
|
350
|
+
mapped = converted.mapped;
|
|
351
|
+
}
|
|
352
|
+
if (!apiRows.length) return text('The file parsed but had no data rows under the header, so there is nothing to import.', true);
|
|
353
|
+
|
|
354
|
+
const totals = { received: 0, created: 0, tagged_existing: 0, already_on_list: 0, invalid_email: 0, duplicate_in_file: 0, tag_failed: 0 };
|
|
355
|
+
const invalidExamples = [];
|
|
356
|
+
const problems = [];
|
|
357
|
+
let list = null;
|
|
358
|
+
for (let i = 0; i < apiRows.length; i += IMPORT_CHUNK) {
|
|
359
|
+
const chunk = apiRows.slice(i, i + IMPORT_CHUNK);
|
|
360
|
+
const chunkNo = i / IMPORT_CHUNK + 1;
|
|
361
|
+
const r = await call('POST', '/marketing/lists/import', { list_name, rows: chunk, description, source });
|
|
362
|
+
if (routeMissing(r)) return text(NOT_SUPPORTED, true);
|
|
363
|
+
if (r.status === 402) return out(r);
|
|
364
|
+
const d = r.data && typeof r.data === 'object' ? r.data : {};
|
|
365
|
+
if (!r.ok && r.status !== 207) {
|
|
366
|
+
problems.push(`chunk ${chunkNo} (rows ${i + 1}-${i + chunk.length}): ${d.message || JSON.stringify(d).slice(0, 300)}`);
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
totals.received += Number(d.received) || 0;
|
|
370
|
+
totals.created += Number(d.created) || 0;
|
|
371
|
+
totals.tagged_existing += Number(d.tagged_existing) || 0;
|
|
372
|
+
totals.already_on_list += Number(d.already_on_list) || 0;
|
|
373
|
+
for (const k of ['invalid_email', 'duplicate_in_file', 'tag_failed']) totals[k] += Number(d.skipped?.[k]) || 0;
|
|
374
|
+
for (const ex of d.invalid_examples || []) if (invalidExamples.length < 10) invalidExamples.push(ex);
|
|
375
|
+
if (d.insert_error) problems.push(`chunk ${chunkNo}: ${d.note_on_error || d.insert_error}`);
|
|
376
|
+
if (d.segment_error) problems.push(d.segment_error);
|
|
377
|
+
if (d.list) list = d.list;
|
|
378
|
+
}
|
|
379
|
+
const ok = problems.length === 0 && Boolean(list && list.id);
|
|
380
|
+
const summary = {
|
|
381
|
+
ok,
|
|
382
|
+
list,
|
|
383
|
+
chunks: Math.ceil(apiRows.length / IMPORT_CHUNK),
|
|
384
|
+
...(mapped ? { columns_used: mapped } : {}),
|
|
385
|
+
...totals,
|
|
386
|
+
invalid_examples: invalidExamples,
|
|
387
|
+
...(problems.length ? { problems } : {}),
|
|
388
|
+
where: 'https://hub.kivimedia.co/?page=email-segments',
|
|
389
|
+
note: ok
|
|
390
|
+
? `"${list.name}" now has ${list.members ?? '?'} members and is ready to pick as an audience in KM Hub. Nothing was sent.`
|
|
391
|
+
: 'The import did not fully land - see problems. Re-sending the same file is safe: existing contacts are tagged, not duplicated.',
|
|
392
|
+
};
|
|
393
|
+
return { content: [{ type: 'text', text: JSON.stringify(summary, null, 2) }], isError: !ok };
|
|
394
|
+
},
|
|
395
|
+
);
|
|
396
|
+
}
|