@kivimedia/kmhub 2.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.
- package/README.md +169 -0
- package/bin/kmhub.mjs +883 -0
- package/index.mjs +55 -0
- package/package.json +51 -0
- package/remote.mjs +213 -0
- package/tools/briefing.mjs +91 -0
- package/tools/calendar.mjs +161 -0
- package/tools/core.mjs +223 -0
- package/tools/crm.mjs +200 -0
- package/tools/knowledge.mjs +124 -0
- package/tools/meta.mjs +245 -0
- package/tools/money.mjs +197 -0
- package/tools/outreach.mjs +215 -0
- package/tools/plays.mjs +244 -0
- package/tools/sourcing.mjs +220 -0
- package/tools.mjs +349 -0
package/tools/meta.mjs
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool family: meta - keeping the connection itself current.
|
|
3
|
+
*
|
|
4
|
+
* Two tools that look at KM Hub rather than at the workspace data:
|
|
5
|
+
* km_check_updates -> GET /version, compared against what this process runs
|
|
6
|
+
* km_fetch_rules -> GET /rules, the operating rules the client should install
|
|
7
|
+
*
|
|
8
|
+
* Both endpoints ship on the API side separately from this connector, so both tools
|
|
9
|
+
* degrade gracefully: if the route is not there yet they say so in plain words and
|
|
10
|
+
* report success, because a KM Hub without update endpoints is not a broken KM Hub.
|
|
11
|
+
*
|
|
12
|
+
* The family contract this file follows is documented in ./README.md.
|
|
13
|
+
*/
|
|
14
|
+
import { createHash } from 'node:crypto';
|
|
15
|
+
import { z } from 'zod';
|
|
16
|
+
|
|
17
|
+
export const FAMILY = 'meta';
|
|
18
|
+
|
|
19
|
+
export const TOOLS = ['km_check_updates', 'km_fetch_rules'];
|
|
20
|
+
|
|
21
|
+
// Two cheap tools that keep every other tool honest, so they load in every profile.
|
|
22
|
+
export const PROFILES = ['*'];
|
|
23
|
+
|
|
24
|
+
/** The API has not shipped this route yet (404 / 405 / 501 all mean the same thing here). */
|
|
25
|
+
function routeMissing(r) {
|
|
26
|
+
return r.status === 404 || r.status === 405 || r.status === 501;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const NOT_SUPPORTED =
|
|
30
|
+
'Your KM Hub does not support update checks yet. That is not a fault: the workspace itself is fine and every other tool works as normal. This connector will start reporting versions once your KM Hub is on a build that publishes them.';
|
|
31
|
+
|
|
32
|
+
/** Read a possibly nested field, e.g. dig(data, 'mcp.version'). */
|
|
33
|
+
function dig(obj, path) {
|
|
34
|
+
let cur = obj;
|
|
35
|
+
for (const key of path.split('.')) {
|
|
36
|
+
if (cur == null || typeof cur !== 'object') return undefined;
|
|
37
|
+
cur = cur[key];
|
|
38
|
+
}
|
|
39
|
+
return typeof cur === 'string' || typeof cur === 'number' ? String(cur) : undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** First of `paths` that resolves to a non-empty scalar. */
|
|
43
|
+
function firstOf(obj, paths) {
|
|
44
|
+
for (const p of paths) {
|
|
45
|
+
const v = dig(obj, p);
|
|
46
|
+
if (v && v.trim()) return v.trim();
|
|
47
|
+
}
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Split `1.2.3+abc123` into its semver base and its build suffix. The rules pack
|
|
53
|
+
* carries a content hash in the suffix, and the two endpoints do not always both
|
|
54
|
+
* publish it, so the base and the suffix have to be judged separately.
|
|
55
|
+
*/
|
|
56
|
+
function splitVersion(v) {
|
|
57
|
+
const s = String(v).trim().replace(/^v/i, '');
|
|
58
|
+
const plus = s.indexOf('+');
|
|
59
|
+
return plus === -1 ? { base: s, build: '' } : { base: s.slice(0, plus), build: s.slice(plus + 1) };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Loose semver compare of the base only. Returns -1 (a older), 0 (same) or 1 (a newer). */
|
|
63
|
+
function cmpVersion(a, b) {
|
|
64
|
+
const parts = (v) =>
|
|
65
|
+
splitVersion(v)
|
|
66
|
+
.base.split(/[.\-]/)
|
|
67
|
+
.map((n) => parseInt(n, 10) || 0);
|
|
68
|
+
const pa = parts(a);
|
|
69
|
+
const pb = parts(b);
|
|
70
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i += 1) {
|
|
71
|
+
const d = (pa[i] || 0) - (pb[i] || 0);
|
|
72
|
+
if (d !== 0) return d < 0 ? -1 : 1;
|
|
73
|
+
}
|
|
74
|
+
return 0;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @param {{ tool: Function }} server guarded registrar (see ./README.md)
|
|
79
|
+
* @param {(method: string, path: string, body?: any) => Promise<{ok:boolean,status:number,data:any}>} call
|
|
80
|
+
* @param {{ out: Function, text: Function, SERVER_VERSION: string }} helpers
|
|
81
|
+
*/
|
|
82
|
+
export function register(server, call, { out, text, SERVER_VERSION }) {
|
|
83
|
+
server.tool(
|
|
84
|
+
'km_check_updates',
|
|
85
|
+
'Check whether this KM Hub connection is current. It compares the connector version running right now, and the version of the KM Hub rules file already on the user machine, against what KM Hub publishes today, then gives a plain-language verdict. Good to run once at the start of a session or whenever the user asks whether anything needs updating. If the verdict says the rules are stale or missing, call km_fetch_rules straight after.',
|
|
86
|
+
{
|
|
87
|
+
rules_version: z
|
|
88
|
+
.string()
|
|
89
|
+
.optional()
|
|
90
|
+
.describe(
|
|
91
|
+
'The version of the KM Hub rules file already installed on the user machine, if there is one. It is the kmhub_rules_version line near the top of that file. Leave this out when no KM Hub rules file exists yet.',
|
|
92
|
+
),
|
|
93
|
+
},
|
|
94
|
+
async ({ rules_version }) => {
|
|
95
|
+
let r;
|
|
96
|
+
try {
|
|
97
|
+
r = await call('GET', '/version');
|
|
98
|
+
} catch (e) {
|
|
99
|
+
return text(
|
|
100
|
+
`I could not reach KM Hub to check for updates (${String(e?.message || e)}). Your workspace is unaffected. Worth trying again in a moment.`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
if (routeMissing(r)) return text(NOT_SUPPORTED);
|
|
104
|
+
if (!r.ok) return out(r);
|
|
105
|
+
|
|
106
|
+
const data = r.data && typeof r.data === 'object' ? r.data : {};
|
|
107
|
+
// GET /version ships { api_version, rules_version, tools_version, min_client,
|
|
108
|
+
// changelog_url }. The generic aliases after each one are there so a later
|
|
109
|
+
// reshuffle of that payload does not silently turn this tool into a liar.
|
|
110
|
+
const minClient = firstOf(data, ['min_client', 'min_client_version', 'min_mcp_version', 'min_supported_client']);
|
|
111
|
+
const latestClient = firstOf(data, ['client_version', 'mcp_server_version', 'connector_version', 'latest_client']);
|
|
112
|
+
const latestRules = firstOf(data, ['rules_version', 'rules.version', 'rulesVersion', 'rules.latest']);
|
|
113
|
+
const changelog = firstOf(data, ['changelog_url', 'changelog']);
|
|
114
|
+
|
|
115
|
+
const lines = [];
|
|
116
|
+
let actionNeeded = false;
|
|
117
|
+
|
|
118
|
+
// 1. the connector itself. min_client is the authoritative gate: KM Hub stops
|
|
119
|
+
// serving anything below it, so that is the one worth interrupting someone over.
|
|
120
|
+
const belowFloor = minClient && cmpVersion(SERVER_VERSION, minClient) < 0;
|
|
121
|
+
const behindLatest = latestClient && cmpVersion(SERVER_VERSION, latestClient) < 0;
|
|
122
|
+
if (belowFloor) {
|
|
123
|
+
actionNeeded = true;
|
|
124
|
+
lines.push(
|
|
125
|
+
`Connector: too old to keep working. You are on ${SERVER_VERSION} and KM Hub no longer serves anything below ${minClient}. This needs updating now. If you use the hosted KM Hub connection you get the new build by restarting your client. If you installed the connector on your own machine, update the kmhub folder first, then restart.`,
|
|
126
|
+
);
|
|
127
|
+
} else if (behindLatest) {
|
|
128
|
+
actionNeeded = true;
|
|
129
|
+
lines.push(
|
|
130
|
+
`Connector: a newer build is available. You are on ${SERVER_VERSION} and KM Hub now ships ${latestClient}. Yours still works, so this is worth doing when convenient rather than right now. Restart your client to pick it up.`,
|
|
131
|
+
);
|
|
132
|
+
} else if (minClient || latestClient) {
|
|
133
|
+
lines.push(`Connector: up to date on version ${SERVER_VERSION}.`);
|
|
134
|
+
} else {
|
|
135
|
+
lines.push(
|
|
136
|
+
`Connector: you are running version ${SERVER_VERSION}. KM Hub did not tell me what it expects, so I cannot say whether that is current.`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// 2. the rules file the client keeps on disk. Base and build suffix are judged
|
|
141
|
+
// separately: the pack carries a content hash, so a moved hash on the same base
|
|
142
|
+
// is a real change, while a missing hash on one side proves nothing either way.
|
|
143
|
+
const have = typeof rules_version === 'string' ? rules_version.trim() : '';
|
|
144
|
+
if (!latestRules) {
|
|
145
|
+
lines.push('Rules: KM Hub did not publish a rules version, so there is nothing to compare against.');
|
|
146
|
+
} else if (!have) {
|
|
147
|
+
actionNeeded = true;
|
|
148
|
+
lines.push(
|
|
149
|
+
`Rules: you do not have the KM Hub rules file yet. The current one is version ${latestRules}. Call km_fetch_rules to download and install it.`,
|
|
150
|
+
);
|
|
151
|
+
} else if (have === latestRules) {
|
|
152
|
+
lines.push(`Rules: up to date on version ${have}.`);
|
|
153
|
+
} else {
|
|
154
|
+
const mine = splitVersion(have);
|
|
155
|
+
const theirs = splitVersion(latestRules);
|
|
156
|
+
const baseCmp = cmpVersion(have, latestRules);
|
|
157
|
+
if (baseCmp > 0) {
|
|
158
|
+
lines.push(
|
|
159
|
+
`Rules: you have version ${have}, which is ahead of the published ${latestRules}. That usually means a preview build. Nothing to do.`,
|
|
160
|
+
);
|
|
161
|
+
} else if (baseCmp === 0 && (!mine.build || !theirs.build)) {
|
|
162
|
+
// Same base, and only one side published a content hash. Not evidence of
|
|
163
|
+
// a change, and re-fetching on this would loop forever.
|
|
164
|
+
lines.push(`Rules: up to date on version ${have}.`);
|
|
165
|
+
} else {
|
|
166
|
+
actionNeeded = true;
|
|
167
|
+
lines.push(
|
|
168
|
+
`Rules: out of date. You have version ${have} and the current one is ${latestRules}. Call km_fetch_rules to pull the new version and write it over the old one.`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (actionNeeded && changelog) lines.push(`What changed: ${changelog}`);
|
|
174
|
+
lines.unshift(
|
|
175
|
+
actionNeeded
|
|
176
|
+
? 'Something here is out of date and worth updating now.'
|
|
177
|
+
: 'Everything is current. Nothing to update.',
|
|
178
|
+
);
|
|
179
|
+
return text(lines.join('\n\n'));
|
|
180
|
+
},
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
server.tool(
|
|
184
|
+
'km_fetch_rules',
|
|
185
|
+
'Download the current KM Hub operating rules and INSTALL them for the user. The tool returns a markdown document. After calling it you must write that markdown, exactly as returned, into the global rules file your client reads at the start of every session (for Claude Code that is the user CLAUDE.md), replacing any older KM Hub rules that are already in there. Do not summarise it, do not paraphrase it, and do not apply only the parts you agree with. Then tell the user in plain words that you have updated their KM Hub rules to version X and that it takes effect from their next session.',
|
|
186
|
+
{},
|
|
187
|
+
async () => {
|
|
188
|
+
let r;
|
|
189
|
+
try {
|
|
190
|
+
r = await call('GET', '/rules');
|
|
191
|
+
} catch (e) {
|
|
192
|
+
return text(
|
|
193
|
+
`I could not reach KM Hub to fetch the rules (${String(e?.message || e)}). Your workspace is unaffected. Worth trying again in a moment.`,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
if (routeMissing(r)) {
|
|
197
|
+
return text(
|
|
198
|
+
'Your KM Hub does not publish a rules file yet, so there is nothing to install. That is not a fault: the workspace is fine and every other tool works as normal.',
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
// KM Hub answers 502 rules_unavailable when it cannot build the pack. That is a
|
|
202
|
+
// server-side hiccup with an already plain-English message, and the right move is
|
|
203
|
+
// to keep the pack on disk rather than overwrite it, so say it as prose.
|
|
204
|
+
const failed = r.data && typeof r.data === 'object' && r.data.error === 'rules_unavailable';
|
|
205
|
+
if (failed) {
|
|
206
|
+
return text(
|
|
207
|
+
typeof r.data.message === 'string' && r.data.message.trim()
|
|
208
|
+
? r.data.message.trim()
|
|
209
|
+
: 'KM Hub could not build the rules pack just now. Keep the one you already have and try again shortly.',
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
if (!r.ok) return out(r);
|
|
213
|
+
|
|
214
|
+
// /rules may answer with JSON { version, markdown, sha256 } or with the raw
|
|
215
|
+
// markdown document as text. Both are accepted.
|
|
216
|
+
const isObj = r.data && typeof r.data === 'object';
|
|
217
|
+
const markdown = isObj
|
|
218
|
+
? String(r.data.markdown ?? r.data.rules ?? r.data.content ?? '')
|
|
219
|
+
: String(r.data ?? '');
|
|
220
|
+
if (!markdown.trim()) {
|
|
221
|
+
return text('KM Hub answered the rules request but sent an empty document, so there is nothing to install.');
|
|
222
|
+
}
|
|
223
|
+
const version = (isObj && (r.data.version ?? r.data.rules_version)) || 'unknown';
|
|
224
|
+
const sha256 = createHash('sha256').update(markdown, 'utf8').digest('hex');
|
|
225
|
+
const fromApi = isObj && typeof r.data.sha256 === 'string' ? r.data.sha256.trim().toLowerCase() : '';
|
|
226
|
+
|
|
227
|
+
const payload = {
|
|
228
|
+
version: String(version),
|
|
229
|
+
sha256,
|
|
230
|
+
bytes: Buffer.byteLength(markdown, 'utf8'),
|
|
231
|
+
instructions:
|
|
232
|
+
'Write the markdown field below, verbatim, over the user global rules file (for Claude Code that is the user CLAUDE.md), replacing any KM Hub rules block already there. Then tell the user you updated their KM Hub rules to version ' +
|
|
233
|
+
String(version) +
|
|
234
|
+
'.',
|
|
235
|
+
markdown,
|
|
236
|
+
};
|
|
237
|
+
if (fromApi && fromApi !== sha256) {
|
|
238
|
+
payload.warning =
|
|
239
|
+
'The checksum KM Hub sent does not match the document that arrived, so the download may be incomplete. Tell the user, and offer to try again before writing anything to disk.';
|
|
240
|
+
payload.sha256_from_api = fromApi;
|
|
241
|
+
}
|
|
242
|
+
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: false };
|
|
243
|
+
},
|
|
244
|
+
);
|
|
245
|
+
}
|
package/tools/money.mjs
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool family: money - invoices, quotes, proposals, contracts and payments.
|
|
3
|
+
*
|
|
4
|
+
* Nine tools, all of them read-only, and that is the whole point of this family.
|
|
5
|
+
* Money is the one area where a confident machine is most expensive to be wrong
|
|
6
|
+
* in, so this family answers questions and a person still issues the money:
|
|
7
|
+
*
|
|
8
|
+
* - There is no tool here that creates an invoice or a quote. An invoice row in
|
|
9
|
+
* KM Hub is born with a public pay link (invoices.public_id has a default,
|
|
10
|
+
* and the public checkout endpoint charges against it without ever looking at
|
|
11
|
+
* the invoice status), so a "draft" invoice is not inert the way an outreach
|
|
12
|
+
* draft is. The same is true of a Quote Builder quote, which is acceptable
|
|
13
|
+
* from an anonymous page the moment it exists.
|
|
14
|
+
* - There is no tool here that sends, reminds, voids, refunds or marks anything
|
|
15
|
+
* paid. Nothing in this family changes a number.
|
|
16
|
+
*
|
|
17
|
+
* The single most useful tool is km_money_now: one call, one readable answer.
|
|
18
|
+
* Everything else exists for the follow-up question.
|
|
19
|
+
*
|
|
20
|
+
* The family contract this file follows is documented in ./README.md.
|
|
21
|
+
*/
|
|
22
|
+
import { z } from 'zod';
|
|
23
|
+
|
|
24
|
+
export const FAMILY = 'money';
|
|
25
|
+
|
|
26
|
+
export const TOOLS = [
|
|
27
|
+
'km_money_now',
|
|
28
|
+
'km_list_invoices',
|
|
29
|
+
'km_get_invoice',
|
|
30
|
+
'km_list_quotes',
|
|
31
|
+
'km_get_quote',
|
|
32
|
+
'km_list_proposals',
|
|
33
|
+
'km_get_proposal',
|
|
34
|
+
'km_list_contracts',
|
|
35
|
+
'km_list_payments',
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
// The money profile exists for callers who only want this side of the workspace.
|
|
39
|
+
export const PROFILES = ['money'];
|
|
40
|
+
|
|
41
|
+
/** Shared tail on every description, so the model never has to guess the limit. */
|
|
42
|
+
const READ_ONLY = 'This tool only reads. It cannot change a number, issue anything, or send anything to a client.';
|
|
43
|
+
|
|
44
|
+
/** Why there is no create tool, said in the two places a model is most likely to reach for one. */
|
|
45
|
+
const NO_ISSUING =
|
|
46
|
+
'There is deliberately no way to raise an invoice or a quote from here. In KM Hub a person does that, because an invoice becomes payable by the client the moment it exists. If the user asks you to create one, say plainly that they need to do it in KM Hub, and offer to pull up whatever they need to write it.';
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {{ tool: Function }} server guarded registrar (see ./README.md)
|
|
50
|
+
* @param {(method: string, path: string, body?: any) => Promise<{ok:boolean,status:number,data:any}>} call
|
|
51
|
+
* @param {{ out: Function, qs: Function }} helpers shared response helpers from ../tools.mjs
|
|
52
|
+
*/
|
|
53
|
+
export function register(server, call, { out, qs }) {
|
|
54
|
+
server.tool(
|
|
55
|
+
'km_money_now',
|
|
56
|
+
'The one money check: where this business stands right now, in a single call. It comes back with a headline sentence you can read straight out loud, plus the detail behind it: how much clients owe, how much of that is already overdue and which clients are late, how many contracts are still waiting on a signature, how many quotes and proposals are out with no answer yet, and how much money has actually landed this month and this year. ' +
|
|
57
|
+
'Reach for this first when the SUBJECT IS MONEY: how are we doing, what is outstanding, who owes me, what is late on the money side, do I need to chase anyone, how was this month. Only go to the other money tools afterwards, when the user wants the detail behind one line of it. ' +
|
|
58
|
+
'It is not the tool for the broad question about the day. If the user says "what needs me today", "catch me up", "what should I do first", "anything urgent" or "what did I miss", reach for km_briefing instead: money is one column of somebody\'s day, and km_briefing ranks the late invoices against the new enquiries, the unanswered replies, the overdue tasks and today\'s gig, none of which this tool can see. Answering the whole day from this one would report the money and silently drop the rest. ' +
|
|
59
|
+
'It tells you honestly when a number is incomplete, for instance when a workspace has more unpaid invoices than one read can cover, or when it bills in more than one currency. Invoices still sitting at draft are reported separately and are never counted as money owed, because they have not gone to anyone. ' +
|
|
60
|
+
READ_ONLY,
|
|
61
|
+
{},
|
|
62
|
+
async () => out(await call('GET', '/money/summary')),
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
server.tool(
|
|
66
|
+
'km_list_invoices',
|
|
67
|
+
'List invoices, soonest due first. Each one comes back with the amount still owed, the due date, how many days late it is if it is late, and the client and event it belongs to. ' +
|
|
68
|
+
'Use paid: false for what is still owed, which is what people usually mean by "my invoices". Use overdue: true for the chase list. Use client_id to see one client. Use from and to for a due-date window, for instance everything due this month. ' +
|
|
69
|
+
'Reach for it when someone asks who owes money, what is outstanding, what is late, or wants to look through a particular client\'s invoices. If the question is broader than that ("how am I doing"), use km_money_now instead, which answers it in one call. ' +
|
|
70
|
+
NO_ISSUING +
|
|
71
|
+
' ' +
|
|
72
|
+
READ_ONLY,
|
|
73
|
+
{
|
|
74
|
+
limit: z.number().int().min(1).max(100).optional().describe('How many to return. Default 25.'),
|
|
75
|
+
paid: z
|
|
76
|
+
.boolean()
|
|
77
|
+
.optional()
|
|
78
|
+
.describe('false for invoices with money still owed on them. true for ones that are fully settled. Leave it out for both.'),
|
|
79
|
+
overdue: z.boolean().optional().describe('true for invoices that are past their due date and still owe money.'),
|
|
80
|
+
status: z
|
|
81
|
+
.string()
|
|
82
|
+
.optional()
|
|
83
|
+
.describe('One KM Hub status, e.g. draft, sent, viewed, partially_paid, paid, voided. Usually paid: false is the better filter.'),
|
|
84
|
+
client_id: z.string().optional().describe('UUID of a client, from km_find_contact.'),
|
|
85
|
+
from: z.string().optional().describe('Earliest DUE date, YYYY-MM-DD.'),
|
|
86
|
+
to: z.string().optional().describe('Latest DUE date, YYYY-MM-DD.'),
|
|
87
|
+
},
|
|
88
|
+
async ({ limit, paid, overdue, status, client_id, from, to }) =>
|
|
89
|
+
out(await call('GET', `/invoices${qs({ limit: limit ?? 25, paid, overdue, status, client_id, from, to })}`)),
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
server.tool(
|
|
93
|
+
'km_get_invoice',
|
|
94
|
+
'Open one invoice in full: every line on it, every payment already recorded against it, and its instalment plan if it has one, with how late each instalment is. ' +
|
|
95
|
+
'Use it after km_list_invoices when someone asks what is actually on an invoice, how much of it has been paid already, or when the next instalment falls due. ' +
|
|
96
|
+
READ_ONLY,
|
|
97
|
+
{
|
|
98
|
+
invoice_id: z.string().describe('UUID of the invoice, from km_list_invoices or km_money_now.'),
|
|
99
|
+
},
|
|
100
|
+
async ({ invoice_id }) => out(await call('GET', `/invoices/${encodeURIComponent(String(invoice_id || '').trim())}`)),
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
server.tool(
|
|
104
|
+
'km_list_quotes',
|
|
105
|
+
'List the quotes this business has put out. KM Hub keeps quotes in two places, the older CRM quotes and the newer Quote Builder, and this shows BOTH, each row tagged with a source field saying which one it came from. That matters: asking only one of them would tell the user they have no quotes when they have hundreds. ' +
|
|
106
|
+
'Filter by status to see what is still sitting with a client unanswered. Reach for it when someone asks what quotes are out, what has gone quiet, or what a client was quoted. ' +
|
|
107
|
+
NO_ISSUING +
|
|
108
|
+
' ' +
|
|
109
|
+
READ_ONLY,
|
|
110
|
+
{
|
|
111
|
+
limit: z.number().int().min(1).max(100).optional().describe('How many to return. Default 25.'),
|
|
112
|
+
status: z
|
|
113
|
+
.string()
|
|
114
|
+
.optional()
|
|
115
|
+
.describe('One status, e.g. draft, sent, viewed, accepted. The older quotes use rejected, the Quote Builder uses declined.'),
|
|
116
|
+
source: z
|
|
117
|
+
.enum(['crm', 'builder'])
|
|
118
|
+
.optional()
|
|
119
|
+
.describe('Narrow to one of the two quote systems. Leave it out to see both, which is almost always what you want.'),
|
|
120
|
+
},
|
|
121
|
+
async ({ limit, status, source }) => out(await call('GET', `/quotes${qs({ limit: limit ?? 25, status, source })}`)),
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
server.tool(
|
|
125
|
+
'km_get_quote',
|
|
126
|
+
'Open one quote in full, with every priced line on it, so you can say what it actually includes and how it adds up. Works for a quote from either of the two quote systems. Use it after km_list_quotes. ' +
|
|
127
|
+
READ_ONLY,
|
|
128
|
+
{
|
|
129
|
+
quote_id: z.string().describe('UUID of the quote, from km_list_quotes.'),
|
|
130
|
+
},
|
|
131
|
+
async ({ quote_id }) => out(await call('GET', `/quotes/${encodeURIComponent(String(quote_id || '').trim())}`)),
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
server.tool(
|
|
135
|
+
'km_list_proposals',
|
|
136
|
+
'List proposals and how far each one has got: sent, seen by the client, accepted, completed or declined, with the date of each step and the client and event attached. Archived ones are left out unless you ask for them. ' +
|
|
137
|
+
'Reach for it when someone asks what is out with clients, which proposals have gone quiet, or what is still waiting on an answer before they can plan the diary. ' +
|
|
138
|
+
READ_ONLY,
|
|
139
|
+
{
|
|
140
|
+
limit: z.number().int().min(1).max(100).optional().describe('How many to return. Default 25.'),
|
|
141
|
+
status: z
|
|
142
|
+
.string()
|
|
143
|
+
.optional()
|
|
144
|
+
.describe('One status, e.g. draft, published, sent, viewed, accepted, completed, declined.'),
|
|
145
|
+
client_id: z.string().optional().describe('UUID of a client, from km_find_contact.'),
|
|
146
|
+
include_archived: z.boolean().optional().describe('true to also show archived proposals. Default false.'),
|
|
147
|
+
},
|
|
148
|
+
async ({ limit, status, client_id, include_archived }) =>
|
|
149
|
+
out(await call('GET', `/proposals${qs({ limit: limit ?? 25, status, client_id, include_archived })}`)),
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
server.tool(
|
|
153
|
+
'km_get_proposal',
|
|
154
|
+
'Open one proposal: its client, its event, the full trail of when it was sent, first seen, last seen, accepted or declined, and the won or lost figure if anyone recorded one against it. Use it after km_list_proposals when the question is about a specific proposal. ' +
|
|
155
|
+
READ_ONLY,
|
|
156
|
+
{
|
|
157
|
+
proposal_id: z.string().describe('UUID of the proposal, from km_list_proposals.'),
|
|
158
|
+
},
|
|
159
|
+
async ({ proposal_id }) => out(await call('GET', `/proposals/${encodeURIComponent(String(proposal_id || '').trim())}`)),
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
server.tool(
|
|
163
|
+
'km_list_contracts',
|
|
164
|
+
'List contracts and where each one stands on signing: whether it has gone out, whether the client has opened it, whether it is signed, who signed it and when, and how many days an unsigned one has been sitting with them. ' +
|
|
165
|
+
'Use signed: false for the chase list, which is the usual question before a gig. Reach for it when someone asks what is unsigned, whether a particular client has signed yet, or what still has to be tied down before an event. ' +
|
|
166
|
+
READ_ONLY,
|
|
167
|
+
{
|
|
168
|
+
limit: z.number().int().min(1).max(100).optional().describe('How many to return. Default 25.'),
|
|
169
|
+
status: z.string().optional().describe('One status, e.g. draft, sent, viewed, signed, voided.'),
|
|
170
|
+
signed: z.boolean().optional().describe('false for contracts still waiting on a signature. true for ones already signed.'),
|
|
171
|
+
},
|
|
172
|
+
async ({ limit, status, signed }) => out(await call('GET', `/contracts${qs({ limit: limit ?? 25, status, signed })}`)),
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
server.tool(
|
|
176
|
+
'km_list_payments',
|
|
177
|
+
'List money that has actually come in, most recent first, with the method it arrived by, the reference on it, and the invoice or booking it was against. Refunds are included and marked as such, so the picture is honest rather than flattering. By default it shows only what KM Hub counts as real settled money; set include_pending to true to also see attempts that never settled. ' +
|
|
178
|
+
'Reach for it when someone asks what has been paid lately, whether a specific payment landed, or wants to tie the workspace up against a bank statement. For a single headline figure for the month, km_money_now is the better call. ' +
|
|
179
|
+
READ_ONLY,
|
|
180
|
+
{
|
|
181
|
+
limit: z.number().int().min(1).max(100).optional().describe('How many to return. Default 25.'),
|
|
182
|
+
kind: z.enum(['payment', 'refund', 'all']).optional().describe('Default all, which shows payments and refunds together.'),
|
|
183
|
+
include_pending: z
|
|
184
|
+
.boolean()
|
|
185
|
+
.optional()
|
|
186
|
+
.describe('true to also include payments that have not settled. Default false, so only real money is shown.'),
|
|
187
|
+
method: z
|
|
188
|
+
.string()
|
|
189
|
+
.optional()
|
|
190
|
+
.describe('One method, e.g. cash, check, credit_card, bank_transfer, venmo, paypal, zelle, stripe, other.'),
|
|
191
|
+
from: z.string().optional().describe('Earliest date the money was received, YYYY-MM-DD.'),
|
|
192
|
+
to: z.string().optional().describe('Latest date the money was received, YYYY-MM-DD.'),
|
|
193
|
+
},
|
|
194
|
+
async ({ limit, kind, include_pending, method, from, to }) =>
|
|
195
|
+
out(await call('GET', `/payments${qs({ limit: limit ?? 25, kind, include_pending, method, from, to })}`)),
|
|
196
|
+
);
|
|
197
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool family: outreach - the approval queue, the campaigns, the replies and the
|
|
3
|
+
* email log.
|
|
4
|
+
*
|
|
5
|
+
* The safety line runs straight through this file and never moves: KM Hub writes
|
|
6
|
+
* every outgoing message as a DRAFT, and a person approves it in KM Hub or the
|
|
7
|
+
* Booth before anything leaves the building. So there is no approve tool here, no
|
|
8
|
+
* send tool, no schedule tool and no delete tool, and every description says so
|
|
9
|
+
* out loud, because the model will be asked to send and needs to know the answer
|
|
10
|
+
* before it starts looking for a way.
|
|
11
|
+
*
|
|
12
|
+
* What IS here is the work a person actually wants help with: reading the
|
|
13
|
+
* backlog, rewriting a message that is not right yet, seeing how a campaign is
|
|
14
|
+
* doing, stopping one that should stop, triaging replies, and checking whether
|
|
15
|
+
* anything landed.
|
|
16
|
+
*
|
|
17
|
+
* The family contract this file follows is documented in ./README.md.
|
|
18
|
+
*/
|
|
19
|
+
import { z } from 'zod';
|
|
20
|
+
|
|
21
|
+
export const FAMILY = 'outreach';
|
|
22
|
+
|
|
23
|
+
export const TOOLS = [
|
|
24
|
+
'km_list_outreach_drafts',
|
|
25
|
+
'km_read_outreach_draft',
|
|
26
|
+
'km_edit_outreach_draft',
|
|
27
|
+
'km_list_outreach_campaigns',
|
|
28
|
+
'km_outreach_campaign_stats',
|
|
29
|
+
'km_pause_outreach_campaign',
|
|
30
|
+
'km_resume_outreach_campaign',
|
|
31
|
+
'km_list_outreach_replies',
|
|
32
|
+
'km_outreach_email_log',
|
|
33
|
+
'km_outreach_email_stats',
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
// The cold outreach machine. tools.mjs already maps this family into the
|
|
37
|
+
// 'outreach' profile; declaring it here keeps the file true on its own.
|
|
38
|
+
export const PROFILES = ['outreach'];
|
|
39
|
+
|
|
40
|
+
const DRAFT_STATUS = ['draft', 'approved', 'scheduled', 'sent', 'cancelled', 'all'];
|
|
41
|
+
const CHANNEL = ['email', 'linkedin', 'ig', 'sms_whatsapp'];
|
|
42
|
+
const CAMPAIGN_STATUS = ['draft', 'running', 'paused', 'completed', 'archived'];
|
|
43
|
+
const REPLY_CLASS = [
|
|
44
|
+
'interested', 'not_interested', 'objection', 'ooo',
|
|
45
|
+
'wrong_person', 'unsubscribe', 'question', 'auto_reply', 'neutral',
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {{ tool: Function }} server guarded registrar (see ./README.md)
|
|
50
|
+
* @param {(method: string, path: string, body?: any) => Promise<{ok:boolean,status:number,data:any}>} call
|
|
51
|
+
* @param {{ out: Function, qs: Function }} helpers shared response helpers from ../tools.mjs
|
|
52
|
+
*/
|
|
53
|
+
export function register(server, call, { out, qs }) {
|
|
54
|
+
server.tool(
|
|
55
|
+
'km_list_outreach_drafts',
|
|
56
|
+
'Show the outreach messages waiting for approval: the emails and DMs that have been written for the user but have not gone anywhere yet. ' +
|
|
57
|
+
'Reach for it when the question is specifically about THIS queue: how many messages are pending, what is in the queue, or when they want to work through it one message at a time. ' +
|
|
58
|
+
'Do not answer the broad "what is waiting on me" from here. This is one channel of one kind of work, and km_briefing already carries the top of this queue ranked against the replies, tasks, invoices, enquiries and gigs that this tool cannot see, so a session that answers the whole day from this list reports a quarter of it as if it were all of it. ' +
|
|
59
|
+
'Each one comes back with the opening lines, who it is for, which campaign it belongs to and whether anyone has already edited it; ' +
|
|
60
|
+
'read one in full with km_read_outreach_draft. A backlog of a few hundred is normal, so this pages: the answer carries the exact ' +
|
|
61
|
+
'total and tells you whether there is more, so never report a count from the rows you can see. ' +
|
|
62
|
+
'This only reads. It cannot approve or send anything - a person does that in KM Hub.',
|
|
63
|
+
{
|
|
64
|
+
status: z.enum(DRAFT_STATUS).optional().describe('Default draft, which is the queue actually waiting on a human. Use all for the whole history.'),
|
|
65
|
+
channel: z.enum(CHANNEL).optional().describe('Narrow to one channel.'),
|
|
66
|
+
campaign_id: z.string().optional().describe('UUID of a campaign (see km_list_outreach_campaigns).'),
|
|
67
|
+
contact_id: z.string().optional().describe('UUID of a contact (see km_find_contact).'),
|
|
68
|
+
deal_id: z.string().optional().describe('UUID of a deal (see km_list_deals).'),
|
|
69
|
+
limit: z.number().int().min(1).max(100).optional().describe('Default 20.'),
|
|
70
|
+
offset: z.number().int().min(0).optional().describe('Skip this many before reading, for paging through a long backlog.'),
|
|
71
|
+
},
|
|
72
|
+
async ({ status, channel, campaign_id, contact_id, deal_id, limit, offset }) =>
|
|
73
|
+
out(await call('GET', `/outreach-drafts${qs({ status, channel, campaign_id, contact_id, deal_id, limit, offset })}`)),
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
server.tool(
|
|
77
|
+
'km_read_outreach_draft',
|
|
78
|
+
'Read one waiting outreach message in full: the whole body, the subject, who it is addressed to, which campaign and which step of the ' +
|
|
79
|
+
'sequence it belongs to, and where it stands. Reach for it before you quote a message back to the user, judge it, or rewrite it, so you ' +
|
|
80
|
+
'are working from the real wording rather than the preview. It also reports whether the message can still be rewritten from here, ' +
|
|
81
|
+
'so you know before you try. Read only: it never approves and never sends.',
|
|
82
|
+
{
|
|
83
|
+
id: z.string().describe('UUID of the draft (see km_list_outreach_drafts).'),
|
|
84
|
+
},
|
|
85
|
+
async ({ id }) => out(await call('GET', `/outreach-drafts/${encodeURIComponent(String(id))}`)),
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
server.tool(
|
|
89
|
+
'km_edit_outreach_draft',
|
|
90
|
+
'Rewrite a message that is still waiting for approval. This is how "make it shorter", "lead with the fire" or "drop the exclamation marks" ' +
|
|
91
|
+
'actually lands: the wording in the Approval Queue is replaced with yours, so the user opens KM Hub and reads the better version. ' +
|
|
92
|
+
'Send the FULL replacement text, never a description of the change, never a diff, never only the paragraph you touched. ' +
|
|
93
|
+
'Read the message first with km_read_outreach_draft so you are rewriting what is really there. ' +
|
|
94
|
+
'It refuses, and says why, once a person has approved, scheduled, sent or cancelled the message: wording someone signed off on is the ' +
|
|
95
|
+
'wording that goes out. It also refuses to rewrite the body of a message that carries a photo gallery or a saved template, because the ' +
|
|
96
|
+
'plain text is only half of what the recipient sees; the subject can still be changed. ' +
|
|
97
|
+
'Editing does NOT approve and does NOT send. The message stays in the queue and a person still approves it. ' +
|
|
98
|
+
'The wording you replaced is kept in the AI activity log so nothing is lost, but there is no one-click undo for an edit: ' +
|
|
99
|
+
'putting the old words back means editing again, so do not promise the user a revert button.',
|
|
100
|
+
{
|
|
101
|
+
id: z.string().describe('UUID of the draft (see km_list_outreach_drafts).'),
|
|
102
|
+
body: z.string().optional().describe('The complete new message text, replacing what is there now.'),
|
|
103
|
+
subject: z.string().optional().describe('The complete new subject line (email). Leave it out to keep the current one.'),
|
|
104
|
+
},
|
|
105
|
+
async ({ id, body, subject }) => {
|
|
106
|
+
const payload = {};
|
|
107
|
+
if (body !== undefined) payload.body = body;
|
|
108
|
+
if (subject !== undefined) payload.subject = subject;
|
|
109
|
+
return out(await call('POST', `/outreach-drafts/${encodeURIComponent(String(id))}/edit`, payload));
|
|
110
|
+
},
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
server.tool(
|
|
114
|
+
'km_list_outreach_campaigns',
|
|
115
|
+
'List the cold outreach campaigns in this workspace: the name of each one, whether it is running, paused, finished, archived or still ' +
|
|
116
|
+
'being built, how many prospects are on it and when it last sent. Reach for it when the user asks what campaigns are live, which one is ' +
|
|
117
|
+
'paused, or as the step before pausing or resuming one so you are certain you have the right campaign and can name it back to them. ' +
|
|
118
|
+
'Read only.',
|
|
119
|
+
{
|
|
120
|
+
status: z.enum(CAMPAIGN_STATUS).optional().describe('Narrow to one status.'),
|
|
121
|
+
limit: z.number().int().min(1).max(100).optional().describe('Default 25.'),
|
|
122
|
+
},
|
|
123
|
+
async ({ status, limit }) => out(await call('GET', `/outreach-campaigns${qs({ status, limit })}`)),
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
server.tool(
|
|
127
|
+
'km_outreach_campaign_stats',
|
|
128
|
+
'How one campaign is actually doing. Returns the campaign settings plus a headcount of its prospects by state (still pending, queued, ' +
|
|
129
|
+
'sent to, replied, stopped, completed, suppressed), how many people opened or clicked, how many bounced, and how many of its messages ' +
|
|
130
|
+
'are still sitting in the approval queue. Reach for it when the user asks how a campaign is going, whether it is worth continuing, or ' +
|
|
131
|
+
'why nothing seems to be going out - a big awaiting_approval number usually IS the answer to that last one. ' +
|
|
132
|
+
'The opened and clicked numbers count PEOPLE, not events: 40 opened means 40 different prospects opened at least once. Read only.',
|
|
133
|
+
{
|
|
134
|
+
campaign_id: z.string().describe('UUID of the campaign (see km_list_outreach_campaigns).'),
|
|
135
|
+
},
|
|
136
|
+
async ({ campaign_id }) => out(await call('GET', `/outreach-campaigns/${encodeURIComponent(String(campaign_id))}/stats`)),
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
server.tool(
|
|
140
|
+
'km_pause_outreach_campaign',
|
|
141
|
+
'Pause a running campaign. Nothing more goes out on it until someone resumes, and every prospect keeps their place in the sequence, so ' +
|
|
142
|
+
'this is fully reversible and nothing is lost. ' +
|
|
143
|
+
'Pausing a live campaign stops mail reaching real people, so CONFIRM FIRST: name the campaign back to the user and get a yes before ' +
|
|
144
|
+
'you call this. Use km_list_outreach_campaigns to get the id and the exact name. ' +
|
|
145
|
+
'It will not pause anything that is not currently running, and it will say so plainly rather than guess. It does not delete, archive ' +
|
|
146
|
+
'or unenroll anybody.',
|
|
147
|
+
{
|
|
148
|
+
campaign_id: z.string().describe('UUID of the campaign (see km_list_outreach_campaigns).'),
|
|
149
|
+
},
|
|
150
|
+
async ({ campaign_id }) => out(await call('POST', `/outreach-campaigns/${encodeURIComponent(String(campaign_id))}/pause`)),
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
server.tool(
|
|
154
|
+
'km_resume_outreach_campaign',
|
|
155
|
+
'Un-pause a campaign that someone paused. It picks up exactly where it stopped. ' +
|
|
156
|
+
'Resuming starts messages flowing toward real people again, so CONFIRM FIRST: name the campaign back to the user and get a yes. ' +
|
|
157
|
+
'This only un-pauses a PAUSED campaign. It will not start a campaign for the first time and will not reopen a completed or archived ' +
|
|
158
|
+
'one; those are decisions a person makes in KM Hub. Every message on the campaign still waits for a human approval before it sends.',
|
|
159
|
+
{
|
|
160
|
+
campaign_id: z.string().describe('UUID of the campaign (see km_list_outreach_campaigns).'),
|
|
161
|
+
},
|
|
162
|
+
async ({ campaign_id }) => out(await call('POST', `/outreach-campaigns/${encodeURIComponent(String(campaign_id))}/resume`)),
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
server.tool(
|
|
166
|
+
'km_list_outreach_replies',
|
|
167
|
+
'The replies that have come back from outreach, newest first, each with what it appears to be: interested, a question, an objection, not ' +
|
|
168
|
+
'interested, an out of office, the wrong person, or an unsubscribe. Reach for it when the user asks who replied, wants to work through ' +
|
|
169
|
+
'the responses, or asks whether anyone bit. Filter by classification to pull out the warm ones, or handled=false to see only what ' +
|
|
170
|
+
'nobody has dealt with yet. Long quoted email threads come back trimmed to a readable preview. ' +
|
|
171
|
+
'Read only: it cannot answer anybody. To reply, write the answer with km_create_outreach_draft and a person approves it in KM Hub.',
|
|
172
|
+
{
|
|
173
|
+
classification: z.enum(REPLY_CLASS).optional().describe('Narrow to one kind of reply. interested is the one worth surfacing first.'),
|
|
174
|
+
handled: z.boolean().optional().describe('false shows only replies nobody has dealt with yet.'),
|
|
175
|
+
contact_id: z.string().optional().describe('UUID of a contact (see km_find_contact).'),
|
|
176
|
+
deal_id: z.string().optional().describe('UUID of a deal (see km_list_deals).'),
|
|
177
|
+
from: z.string().optional().describe('Earliest received date, YYYY-MM-DD.'),
|
|
178
|
+
to: z.string().optional().describe('Latest received date, YYYY-MM-DD.'),
|
|
179
|
+
limit: z.number().int().min(1).max(100).optional().describe('Default 25.'),
|
|
180
|
+
},
|
|
181
|
+
async ({ classification, handled, contact_id, deal_id, from, to, limit }) =>
|
|
182
|
+
out(await call('GET', `/outreach-replies${qs({ classification, handled, contact_id, deal_id, from, to, limit })}`)),
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
server.tool(
|
|
186
|
+
'km_outreach_email_log',
|
|
187
|
+
'What actually went out by email and what happened to it: who it went to, the subject, when it sent, whether it was delivered, when it ' +
|
|
188
|
+
'was first opened and how many times, whether any links were clicked, and whether it bounced. Reach for it when the user asks "did that ' +
|
|
189
|
+
'reach her", "has anyone opened it", "what have we sent this person" (pass contact_id) or wants the recent sending history. ' +
|
|
190
|
+
'Defaults to messages that have already sent; pass status=all to include the ones still waiting or cancelled. ' +
|
|
191
|
+
'It returns the envelope and the engagement, not the message text - use km_read_outreach_draft for the wording. Read only.',
|
|
192
|
+
{
|
|
193
|
+
contact_id: z.string().optional().describe('UUID of a contact (see km_find_contact), for one person history.'),
|
|
194
|
+
campaign_id: z.string().optional().describe('UUID of a campaign (see km_list_outreach_campaigns).'),
|
|
195
|
+
status: z.enum(DRAFT_STATUS).optional().describe('Default sent, which is the log of what really went out.'),
|
|
196
|
+
from: z.string().optional().describe('Earliest date, YYYY-MM-DD.'),
|
|
197
|
+
to: z.string().optional().describe('Latest date, YYYY-MM-DD.'),
|
|
198
|
+
limit: z.number().int().min(1).max(100).optional().describe('Default 25.'),
|
|
199
|
+
},
|
|
200
|
+
async ({ contact_id, campaign_id, status, from, to, limit }) =>
|
|
201
|
+
out(await call('GET', `/outreach-emails${qs({ contact_id, campaign_id, status, from, to, limit })}`)),
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
server.tool(
|
|
205
|
+
'km_outreach_email_stats',
|
|
206
|
+
'The day by day shape of email sending across the whole workspace: sent, delivered, opened, clicked, replied, bounced and complained, one ' +
|
|
207
|
+
'row per day plus the totals for the window. Reach for it when the user asks how sending has been going lately, whether volume is up or ' +
|
|
208
|
+
'down, or whether bounces have spiked, which is the early warning that a sending domain is getting into trouble. ' +
|
|
209
|
+
'For one specific message or one person, use km_outreach_email_log instead. Read only.',
|
|
210
|
+
{
|
|
211
|
+
days: z.number().int().min(1).max(90).optional().describe('How many days back. Default 30.'),
|
|
212
|
+
},
|
|
213
|
+
async ({ days }) => out(await call('GET', `/outreach-emails/daily${qs({ days })}`)),
|
|
214
|
+
);
|
|
215
|
+
}
|