@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/plays.mjs
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool family: plays - the operating intelligence, leased one step at a time.
|
|
3
|
+
*
|
|
4
|
+
* Every other family is a door onto the workspace data. This one is the reason a
|
|
5
|
+
* client would rather have KM Hub in their terminal than a generic assistant with
|
|
6
|
+
* a CRM connector: the plays are the proven ways this kind of business actually
|
|
7
|
+
* gets booked, and they are worth more than any single record they touch.
|
|
8
|
+
*
|
|
9
|
+
* Which is why none of them live on the client's machine. The protection model,
|
|
10
|
+
* stated plainly so nobody has to reverse engineer it from the code:
|
|
11
|
+
*
|
|
12
|
+
* - The client's disk holds three things: a URL, an API key, and a rules file
|
|
13
|
+
* of pure orientation. No play, no runbook, no criterion, ever.
|
|
14
|
+
* - km_play_catalog returns METADATA ONLY: slug, label, and the plain reason a
|
|
15
|
+
* human would care. Never the task, never the criterion, never an internal.
|
|
16
|
+
* - km_play_run leases ONE bounded step per call. The text arrives in the
|
|
17
|
+
* conversation, is used, and is never written to a file by us.
|
|
18
|
+
* - Quotas are what stop somebody enumerating the catalog by running everything
|
|
19
|
+
* once: a cap per hour, a cap per day, and a cap on how many DIFFERENT plays
|
|
20
|
+
* in a rolling day. Repeating a play already seen in that window is free of
|
|
21
|
+
* the variety cap, so normal work never hits it and harvesting does.
|
|
22
|
+
* - Each issued step carries an HMAC watermark. Be honest about what that is:
|
|
23
|
+
* it is forensic attribution, NOT DRM. The task is plaintext in the client's
|
|
24
|
+
* own transcript and a determined person can strip the marker. It tells us
|
|
25
|
+
* WHERE a leaked task came from. It does not stop the leak.
|
|
26
|
+
* - The real kill switch is Phase 1 and it already works: cancel the
|
|
27
|
+
* subscription and the entitlement gate answers 402 on every route here, so
|
|
28
|
+
* no catalog, no runs, no rules refresh, from the next call onwards.
|
|
29
|
+
*
|
|
30
|
+
* Nothing in this family sends anything to anybody. A play that produces a
|
|
31
|
+
* message still ends at km_create_outreach_draft and the Approval Queue.
|
|
32
|
+
*
|
|
33
|
+
* The routes behind it are GET /plays, POST /plays/run and POST /plays/verify.
|
|
34
|
+
* They own the truth about the body shape, so the two POST handlers pass their
|
|
35
|
+
* arguments straight through rather than reshaping them here.
|
|
36
|
+
*
|
|
37
|
+
* The family contract this file follows is documented in ./README.md.
|
|
38
|
+
*/
|
|
39
|
+
import { z } from 'zod';
|
|
40
|
+
|
|
41
|
+
export const FAMILY = 'plays';
|
|
42
|
+
|
|
43
|
+
export const TOOLS = ['km_play_catalog', 'km_play_run', 'km_play_verify'];
|
|
44
|
+
|
|
45
|
+
// Every profile. A session that can read the calendar but cannot reach the plays
|
|
46
|
+
// is a crippled session: the plays are what make the rest of the tools add up to
|
|
47
|
+
// a way of working rather than a pile of endpoints.
|
|
48
|
+
export const PROFILES = ['*'];
|
|
49
|
+
|
|
50
|
+
/** The API has not shipped these routes yet (404 / 405 / 501 all mean the same here). */
|
|
51
|
+
function routeMissing(r) {
|
|
52
|
+
return r.status === 404 || r.status === 405 || r.status === 501;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const NOT_SUPPORTED =
|
|
56
|
+
'This KM Hub does not have the plays switched on yet. Nothing is broken and nothing is lost: the workspace is fine and every other tool works as normal. Carry on with the tools you have, and say plainly that the plays are not available on this account rather than inventing a method and calling it one.';
|
|
57
|
+
|
|
58
|
+
/** Round a retry delay into something a person says out loud. Empty when unknown. */
|
|
59
|
+
function inAbout(seconds) {
|
|
60
|
+
const s = Number(seconds);
|
|
61
|
+
if (!Number.isFinite(s) || s <= 0) return '';
|
|
62
|
+
if (s < 90) return `about ${Math.max(1, Math.round(s))} seconds`;
|
|
63
|
+
const minutes = Math.round(s / 60);
|
|
64
|
+
if (minutes < 90) return `about ${minutes} minute${minutes === 1 ? '' : 's'}`;
|
|
65
|
+
const hours = Math.round(s / 3600);
|
|
66
|
+
return `about ${hours} hour${hours === 1 ? '' : 's'}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Turn a 429 into a sentence a working performer understands.
|
|
71
|
+
*
|
|
72
|
+
* The shared out() helper already does this for a 402, and a refusal is the one
|
|
73
|
+
* moment where a raw status code is least use to anybody. Two different things
|
|
74
|
+
* answer 429 on this API and they need different words:
|
|
75
|
+
*
|
|
76
|
+
* - `rate_limited` from the API front door: too many calls per minute. A pause
|
|
77
|
+
* fixes it.
|
|
78
|
+
* - anything else: the play allowance itself. Not a fault, not a charge, and
|
|
79
|
+
* not something a retry loop will get around.
|
|
80
|
+
*
|
|
81
|
+
* Returns null when the response is not a 429, so callers can carry on normally.
|
|
82
|
+
*/
|
|
83
|
+
function quotaRefusal(r) {
|
|
84
|
+
if (!r || r.status !== 429) return null;
|
|
85
|
+
const body = r.data && typeof r.data === 'object' ? r.data : {};
|
|
86
|
+
const code = String(body.error || body.reason || body.code || '').toLowerCase();
|
|
87
|
+
const said = typeof body.message === 'string' && body.message.trim() ? body.message.trim() : '';
|
|
88
|
+
const wait = inAbout(body.retry_after ?? body.retryAfter ?? body.retry_after_seconds);
|
|
89
|
+
const lines = [];
|
|
90
|
+
|
|
91
|
+
if (code.includes('rate_limit')) {
|
|
92
|
+
lines.push('I am calling KM Hub faster than it allows, so it asked me to slow down. Nothing failed and nothing changed.');
|
|
93
|
+
lines.push(wait ? `It clears in ${wait}.` : 'It clears on its own in well under a minute.');
|
|
94
|
+
if (said) lines.push(said);
|
|
95
|
+
lines.push('Wait it out and try the same call once more. Do not split the work into more calls, which makes it worse.');
|
|
96
|
+
return { content: [{ type: 'text', text: lines.join('\n\n') }], isError: true };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const variety = code.includes('distinct') || code.includes('variety') || code.includes('skill_limit');
|
|
100
|
+
if (variety) {
|
|
101
|
+
lines.push(
|
|
102
|
+
'That is a play this workspace has not opened today, and it has already opened as many different ones as a day allows. Nothing is wrong, nothing was charged, and nothing in the workspace has changed.',
|
|
103
|
+
);
|
|
104
|
+
lines.push(
|
|
105
|
+
'A play already run today can still be run again as much as it is needed, so if one of those covers what is being asked, use it instead. Otherwise this one is worth doing tomorrow.',
|
|
106
|
+
);
|
|
107
|
+
} else {
|
|
108
|
+
lines.push(
|
|
109
|
+
'This workspace has run as many plays as its allowance covers for now. Nothing is wrong, nothing was charged, and nothing in the workspace has changed.',
|
|
110
|
+
);
|
|
111
|
+
lines.push('The allowance is there so the plays get used on real work rather than pulled through in bulk.');
|
|
112
|
+
}
|
|
113
|
+
if (said) lines.push(said);
|
|
114
|
+
lines.push(wait ? `It frees up again in ${wait}.` : 'It frees up again on its own.');
|
|
115
|
+
lines.push(
|
|
116
|
+
'Tell the person that plainly and get on with the work by hand: the clients, the calendar, the drafts and everything else are untouched and still work. Do not retry this in a loop.',
|
|
117
|
+
);
|
|
118
|
+
return { content: [{ type: 'text', text: lines.join('\n\n') }], isError: true };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* @param {{ tool: Function }} server guarded registrar (see ./README.md)
|
|
123
|
+
* @param {(method: string, path: string, body?: any) => Promise<{ok:boolean,status:number,data:any}>} call
|
|
124
|
+
* @param {{ out: Function, text: Function, qs: Function }} helpers
|
|
125
|
+
*/
|
|
126
|
+
export function register(server, call, { out, text }) {
|
|
127
|
+
server.tool(
|
|
128
|
+
'km_play_catalog',
|
|
129
|
+
[
|
|
130
|
+
'The plays: the proven ways a business like this one actually gets booked, kept current by KM Hub and written by people who book work for a living rather than assembled from general marketing advice.',
|
|
131
|
+
'LOOK HERE FIRST whenever the ask is open ended. "What should I do today", "how do I get more corporate work", "nobody is replying to me", "help me with the fair market", "next month looks empty", "how do I get repeat bookings out of this client": there is very often a play for exactly that, and it will be sharper than anything improvised from what you already know about marketing.',
|
|
132
|
+
'Free, fast, sends nothing and changes nothing. What comes back is a short list, and each entry is a slug to run it by, its name, and the plain reason a working performer would care about it.',
|
|
133
|
+
'What it deliberately does NOT contain is the method. The steps are not in here and cannot be worked out from the name, so never describe how a play works or promise what it will produce from the catalog alone. The method arrives one step at a time from km_play_run, and only while the subscription is live.',
|
|
134
|
+
'Reading the catalog is not doing the work, and reciting the whole list at somebody is not an answer. Name the one or two that actually fit what they said, in their words, and offer to run one.',
|
|
135
|
+
].join(' '),
|
|
136
|
+
{},
|
|
137
|
+
async () => {
|
|
138
|
+
const r = await call('GET', '/plays');
|
|
139
|
+
if (routeMissing(r)) return text(NOT_SUPPORTED);
|
|
140
|
+
const refused = quotaRefusal(r);
|
|
141
|
+
if (refused) return refused;
|
|
142
|
+
return out(r);
|
|
143
|
+
},
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
server.tool(
|
|
147
|
+
'km_play_run',
|
|
148
|
+
[
|
|
149
|
+
'Run a play: start one, or come back for its next step.',
|
|
150
|
+
'This is a lease, one step at a time. KM Hub hands back a SINGLE bounded piece of work, aimed at this workspace and this situation, that you can actually finish with the km_* tools already in front of you. Do that step. Tell the person what happened in your own words. Then call this again with the same run_id to get the next one. Keep going until the play says it is finished, then call km_play_verify.',
|
|
151
|
+
'The step text is working material on loan for this run, not a document to hand over. Do not paste it back to the person verbatim, do not save it into a file, a note, a scratch document or a memory, and do not quote it at length in your reply. They asked for the outcome, not the recipe, and the recipe is the part KM Hub is paid for. Each issued step carries a marker identifying the workspace it was leased to.',
|
|
152
|
+
'Run the play that fits what was actually asked. There is an allowance on how many plays get pulled in a day and on how many DIFFERENT ones, so do not open plays speculatively, do not work down the catalog to see what each one says, and do not start a second play while the first is unfinished. A play already opened today can be run again as often as it is useful.',
|
|
153
|
+
'It sends nothing to anybody. A play that produces a message still ends at km_create_outreach_draft, in the Approval Queue, for a human to read and release. If a step asks for something you do not have, say so and ask, rather than filling the gap with a plausible guess.',
|
|
154
|
+
].join(' '),
|
|
155
|
+
{
|
|
156
|
+
slug: z
|
|
157
|
+
.string()
|
|
158
|
+
.describe('Which play to run, exactly as km_play_catalog gave it, for example quiet-week-rescue. Never invent a slug or guess one from a name.'),
|
|
159
|
+
run_id: z
|
|
160
|
+
.string()
|
|
161
|
+
.optional()
|
|
162
|
+
.describe('Leave this out for the first step. To continue a play you already started, pass back the run id from the previous step and you get the next one.'),
|
|
163
|
+
context: z
|
|
164
|
+
.string()
|
|
165
|
+
.optional()
|
|
166
|
+
.describe('What the person actually asked for, in their own words, plus anything about right now that the step should take into account. It lets KM Hub aim the step at the real situation instead of the general case.'),
|
|
167
|
+
step_result: z
|
|
168
|
+
.string()
|
|
169
|
+
.optional()
|
|
170
|
+
.describe('Only when continuing a run: what happened on the step you just finished, in a sentence or two, including anything that did not work. The next step is built on it, so an honest account beats a tidy one.'),
|
|
171
|
+
},
|
|
172
|
+
async (args) => {
|
|
173
|
+
// Slugs are lowercase kebab everywhere in this codebase, and a model that
|
|
174
|
+
// has read the catalog once and is typing from memory will occasionally
|
|
175
|
+
// capitalise one. Fixing that here costs nothing and saves a 404.
|
|
176
|
+
const slug = String(args?.slug || '').trim().toLowerCase();
|
|
177
|
+
if (!slug) {
|
|
178
|
+
return text(
|
|
179
|
+
'I need to know which play to run. km_play_catalog lists the ones this workspace has, with the slug for each.',
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
// The route requires an idempotency_key (8-128 of [A-Za-z0-9._:-]) so a
|
|
183
|
+
// retry cannot double-spend a quota slot. It is minted here, never asked
|
|
184
|
+
// of the model: a caller-chosen key is a footgun, and a model has no
|
|
185
|
+
// basis to invent a stable one. Continuing a run reuses a key derived
|
|
186
|
+
// from the run id so a retried continuation replays instead of counting
|
|
187
|
+
// twice; a fresh run gets a random one.
|
|
188
|
+
const runId = String(args?.run_id || '').trim();
|
|
189
|
+
const idempotency_key = runId
|
|
190
|
+
? `run-${runId.replace(/[^A-Za-z0-9._:-]/g, '')}`.slice(0, 128)
|
|
191
|
+
: crypto.randomUUID().replace(/-/g, '');
|
|
192
|
+
const r = await call('POST', '/plays/run', { ...args, slug, idempotency_key });
|
|
193
|
+
if (routeMissing(r)) return text(NOT_SUPPORTED);
|
|
194
|
+
const refused = quotaRefusal(r);
|
|
195
|
+
if (refused) return refused;
|
|
196
|
+
return out(r);
|
|
197
|
+
},
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
server.tool(
|
|
201
|
+
'km_play_verify',
|
|
202
|
+
[
|
|
203
|
+
'Check whether the play actually worked. Every play has a criterion: the observable thing that should be true now if it did its job. That is a different question from whether you got through the steps, and it is the one that matters.',
|
|
204
|
+
'Call it when a play reaches its end, or whenever someone asks whether the thing you ran last week did anything. KM Hub gives you the criterion, you check it against what you can genuinely see through the km_* tools, and you report what you found.',
|
|
205
|
+
'Being honest here is the whole point of the tool. "I ran it, and I cannot confirm yet that it worked" is a real answer, a useful one, and far better than a confident yes that falls apart a week later. Say which part you could confirm, which part you could not, and what would settle it.',
|
|
206
|
+
'Do not call a play successful because the steps are done. Do not verify a play you did not run. This changes nothing in the workspace, sends nothing to anybody, and cannot make a play have worked.',
|
|
207
|
+
].join(' '),
|
|
208
|
+
{
|
|
209
|
+
run_id: z
|
|
210
|
+
.string()
|
|
211
|
+
.optional()
|
|
212
|
+
.describe('The run being checked, as returned by km_play_run. Give this whenever you have it: it is what ties the criterion to the work that was actually done.'),
|
|
213
|
+
slug: z
|
|
214
|
+
.string()
|
|
215
|
+
.optional()
|
|
216
|
+
.describe('The play being checked, as km_play_catalog gave it. Pass it alongside run_id for context. On its own it is not enough: the check is tied to a specific run, so without the run id from km_play_run there is nothing to judge against.'),
|
|
217
|
+
outcome: z
|
|
218
|
+
.string()
|
|
219
|
+
.optional()
|
|
220
|
+
.describe('What you did and what you can see now, in plain words, so the criterion is judged against reality rather than against intent. Include what did not happen as well as what did.'),
|
|
221
|
+
},
|
|
222
|
+
async (args) => {
|
|
223
|
+
const runId = String(args?.run_id || '').trim();
|
|
224
|
+
const slug = String(args?.slug || '').trim().toLowerCase();
|
|
225
|
+
if (!runId) {
|
|
226
|
+
return text(
|
|
227
|
+
'I need the run id that km_play_run gave back, because the check is tied to that specific run. ' +
|
|
228
|
+
'If it is genuinely lost, run the play again rather than guessing whether the old one worked.',
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
// The route reads `evidence`; this tool calls it `outcome` because that
|
|
232
|
+
// is the friendlier word for a model. Map it, or every call 400s.
|
|
233
|
+
const r = await call('POST', '/plays/verify', {
|
|
234
|
+
run_id: runId || undefined,
|
|
235
|
+
slug: slug || undefined,
|
|
236
|
+
evidence: String(args?.outcome || '').trim() || undefined,
|
|
237
|
+
});
|
|
238
|
+
if (routeMissing(r)) return text(NOT_SUPPORTED);
|
|
239
|
+
const refused = quotaRefusal(r);
|
|
240
|
+
if (refused) return refused;
|
|
241
|
+
return out(r);
|
|
242
|
+
},
|
|
243
|
+
);
|
|
244
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool family: sourcing - finding new people to work with.
|
|
3
|
+
*
|
|
4
|
+
* THIS IS THE ONE FAMILY THAT SPENDS THE CLIENT'S MONEY. Every other family
|
|
5
|
+
* reads or drafts for free. Three tools here reach paid outside services on KM
|
|
6
|
+
* Hub's accounts, and the whole design of this file exists to make sure a person
|
|
7
|
+
* always knows the price before a cent moves:
|
|
8
|
+
*
|
|
9
|
+
* - Every paid tool takes `confirm_spend`. Left out, the tool returns the
|
|
10
|
+
* current price and changes nothing. That first call is free, always.
|
|
11
|
+
* - The price is not written down here. It comes back live from KM Hub, which
|
|
12
|
+
* works it out from what this workspace has actually paid for the same thing.
|
|
13
|
+
* - A workspace that has run out of its monthly allowance is refused with the
|
|
14
|
+
* same sentence it would see in the browser, not quietly charged.
|
|
15
|
+
*
|
|
16
|
+
* Nothing here sends anything to anybody. A scout writes its first message as a
|
|
17
|
+
* DRAFT into the Approval Queue and a human releases it.
|
|
18
|
+
*
|
|
19
|
+
* The family contract this file follows is documented in ./README.md.
|
|
20
|
+
*/
|
|
21
|
+
import { z } from 'zod';
|
|
22
|
+
|
|
23
|
+
export const FAMILY = 'sourcing';
|
|
24
|
+
|
|
25
|
+
export const TOOLS = [
|
|
26
|
+
'km_sourcing_prices',
|
|
27
|
+
'km_start_lead_scout',
|
|
28
|
+
'km_list_lead_scouts',
|
|
29
|
+
'km_lead_scout_status',
|
|
30
|
+
'km_stop_lead_scout',
|
|
31
|
+
'km_find_company_contacts',
|
|
32
|
+
'km_check_email_address',
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
// Finding new people is outreach work, so this family joins that profile. It is
|
|
36
|
+
// not in `core`: a session that only wants the cheap tools should not be handed
|
|
37
|
+
// the spending ones.
|
|
38
|
+
export const PROFILES = ['outreach'];
|
|
39
|
+
|
|
40
|
+
/** The API has not shipped these routes yet. Say so kindly rather than erroring. */
|
|
41
|
+
function routeMissing(r) {
|
|
42
|
+
return r.status === 404 || r.status === 405 || r.status === 501;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const NOT_SUPPORTED =
|
|
46
|
+
'This KM Hub does not have lead sourcing switched on yet. Nothing is broken, and every other tool works as normal. Nothing was charged.';
|
|
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, text: Function, qs: Function }} helpers
|
|
52
|
+
*/
|
|
53
|
+
export function register(server, call, { out, text, qs }) {
|
|
54
|
+
// Prices first: the free tool that makes every paid one safe to reach for.
|
|
55
|
+
|
|
56
|
+
server.tool(
|
|
57
|
+
'km_sourcing_prices',
|
|
58
|
+
'What the paid lead-finding tools cost in this workspace right now. Reading this is free and changes nothing. Reach for it whenever someone asks what finding leads costs, or before you suggest running a scout, so you can tell them the price in their own money rather than guessing. The figures come from what this workspace has actually been charged for the same work, so they get more accurate the more it is used. It will not run anything and it will not charge anything.',
|
|
59
|
+
{},
|
|
60
|
+
async () => {
|
|
61
|
+
const r = await call('GET', '/sourcing/prices');
|
|
62
|
+
if (routeMissing(r)) return text(NOT_SUPPORTED);
|
|
63
|
+
return out(r);
|
|
64
|
+
},
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
// The scout: the big one, and the only asynchronous tool in this family.
|
|
68
|
+
|
|
69
|
+
server.tool(
|
|
70
|
+
'km_start_lead_scout',
|
|
71
|
+
[
|
|
72
|
+
'Send KM Hub off to find new potential clients: it searches for people matching a description, digs out their real email address, checks the address is live, reads up on each business, and writes a first message for each one.',
|
|
73
|
+
'THIS COSTS REAL MONEY and it is the largest single charge in KM Hub. Never quote a price from memory: it depends on how many leads you ask for and it differs per workspace, so the figure comes back live from the first call. Never start one on your own initiative.',
|
|
74
|
+
'How to use it: call it FIRST without confirm_spend. Nothing is charged and nothing runs; you get back the current price for this workspace. Tell the person that price in plain words, wait for them to say yes, and only then call it again with confirm_spend set to true.',
|
|
75
|
+
'Describe who to find in ordinary language, the way the person said it: "wedding planners in Austin who do big corporate parties" works better than a pile of filters. Add the filters only if the person actually named them.',
|
|
76
|
+
'It does not run while you wait. It runs on KM Hub servers over several minutes to an hour, so start it, tell the person it is off and running, and check on it later with km_lead_scout_status. The session can end in between.',
|
|
77
|
+
'What it will NOT do: it never emails anybody. Contacts land in the pipeline and each first message is written as a DRAFT in the Approval Queue for a person to read and approve. It refuses to run at all if the workspace has used up its monthly allowance, and it will say so instead of spending.',
|
|
78
|
+
].join(' '),
|
|
79
|
+
{
|
|
80
|
+
describe: z
|
|
81
|
+
.string()
|
|
82
|
+
.optional()
|
|
83
|
+
.describe('Who to find, in plain English, in the person\'s own words. For example: "owners of boutique hotels in Miami that host weddings". This is the main input - prefer it over the filter lists below.'),
|
|
84
|
+
target_count: z
|
|
85
|
+
.number()
|
|
86
|
+
.int()
|
|
87
|
+
.min(1)
|
|
88
|
+
.max(100)
|
|
89
|
+
.optional()
|
|
90
|
+
.describe('How many contacts to aim for. Default 25. More costs more, and only about one person in three turns out to have a reachable email, so a bigger number also takes longer. Capped at 100 from here.'),
|
|
91
|
+
area: z.string().optional().describe('The town, city or region, if the person named one separately from the description.'),
|
|
92
|
+
titles: z.array(z.string()).optional().describe('Job titles to look for, only if the person named specific ones. For example ["Owner", "Events Manager"].'),
|
|
93
|
+
locations: z.array(z.string()).optional().describe('Cities or regions as separate entries, if the person listed several.'),
|
|
94
|
+
industries: z.array(z.string()).optional().describe('Industries to look in, if the person named them.'),
|
|
95
|
+
keywords: z.array(z.string()).optional().describe('Extra words that should show up on the person or the business.'),
|
|
96
|
+
company_sizes: z.array(z.string()).optional().describe('Company head-count bands, only if the person asked for a size. For example ["1,10", "11,50"].'),
|
|
97
|
+
seniorities: z.array(z.string()).optional().describe('Seniority levels, only if the person asked. For example ["owner", "founder", "director"].'),
|
|
98
|
+
outreach_context: z
|
|
99
|
+
.string()
|
|
100
|
+
.optional()
|
|
101
|
+
.describe('What the person is offering these leads, in a sentence, so the drafted first message is about something real. For example: "balloon installations for corporate galas in Austin, entrance arches and centrepieces".'),
|
|
102
|
+
vertical_flavor: z.string().optional().describe('The trade the workspace is in, if it needs to be forced. Normally leave this out and let KM Hub use the workspace setting.'),
|
|
103
|
+
deep_research: z
|
|
104
|
+
.boolean()
|
|
105
|
+
.optional()
|
|
106
|
+
.describe('Read up on each business before writing to it. On by default. Turning it off makes the run cheaper and faster but the first messages far more generic.'),
|
|
107
|
+
ai_copywriting: z
|
|
108
|
+
.boolean()
|
|
109
|
+
.optional()
|
|
110
|
+
.describe('Write a first message for each contact, as a draft. On by default. Turn it off to just collect verified contacts with no message written.'),
|
|
111
|
+
confirm_spend: z
|
|
112
|
+
.boolean()
|
|
113
|
+
.optional()
|
|
114
|
+
.describe('Leave this out on the first call: you get the price back and nothing is charged. Set it to true ONLY after the person has been told the price and has agreed to it.'),
|
|
115
|
+
},
|
|
116
|
+
async (args) => {
|
|
117
|
+
const r = await call('POST', '/sourcing/missions', args);
|
|
118
|
+
if (routeMissing(r)) return text(NOT_SUPPORTED);
|
|
119
|
+
return out(r);
|
|
120
|
+
},
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
server.tool(
|
|
124
|
+
'km_list_lead_scouts',
|
|
125
|
+
'List the recent lead scouts in this workspace, newest first, with what stage each one is at. Free to call and changes nothing. Reach for it when someone asks whether anything is running, what happened to the search from yesterday, or how the last few went. If one is still going, follow up with km_lead_scout_status for its live progress. It does not start anything and does not charge anything.',
|
|
126
|
+
{
|
|
127
|
+
limit: z.number().int().min(1).max(50).optional().describe('How many to list. Default 10.'),
|
|
128
|
+
status: z
|
|
129
|
+
.enum(['queued', 'running', 'succeeded', 'failed', 'canceled'])
|
|
130
|
+
.optional()
|
|
131
|
+
.describe('Only show scouts in this state. Leave out to see them all.'),
|
|
132
|
+
},
|
|
133
|
+
async ({ limit, status }) => {
|
|
134
|
+
const r = await call('GET', `/sourcing/missions${qs({ limit: limit ?? 10, status })}`);
|
|
135
|
+
if (routeMissing(r)) return text(NOT_SUPPORTED);
|
|
136
|
+
return out(r);
|
|
137
|
+
},
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
server.tool(
|
|
141
|
+
'km_lead_scout_status',
|
|
142
|
+
'Check on one lead scout: what it is doing right now, how many people it has found, verified and written to so far, and its final results once it is done. Free to call and changes nothing. Reach for it after starting a scout, or whenever someone asks how the search is getting on. Report it back as a plain sentence about where it has got to, not as a list of numbers. If it says failed, the error field explains why and the person can start a fresh one. It does not restart anything and does not charge anything.',
|
|
143
|
+
{
|
|
144
|
+
mission_id: z.string().describe('The id of the scout, as returned when it was started or by km_list_lead_scouts.'),
|
|
145
|
+
},
|
|
146
|
+
async ({ mission_id }) => {
|
|
147
|
+
const id = String(mission_id || '').trim();
|
|
148
|
+
if (!id) return text('I need the id of the scout to look it up. km_list_lead_scouts will show you the recent ones.');
|
|
149
|
+
const r = await call('GET', `/sourcing/missions/${encodeURIComponent(id)}`);
|
|
150
|
+
if (routeMissing(r)) return text(NOT_SUPPORTED);
|
|
151
|
+
return out(r);
|
|
152
|
+
},
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
server.tool(
|
|
156
|
+
'km_stop_lead_scout',
|
|
157
|
+
'Stop a lead scout that is queued or already running. Reach for it the moment someone says stop, cancel, that is enough, or realises the search was set up wrong. Stopping is the money-saving move and is always safe: the workspace is only charged for the lookups already made, never for the work the scout did not do. Anything it already found stays in the pipeline, and any message it already wrote stays as a draft in the Approval Queue. It cannot stop a scout that has already finished, and it will say so plainly rather than pretending. It does not delete anything.',
|
|
158
|
+
{
|
|
159
|
+
mission_id: z.string().describe('The id of the scout to stop, from km_list_lead_scouts or from when it was started.'),
|
|
160
|
+
},
|
|
161
|
+
async ({ mission_id }) => {
|
|
162
|
+
const id = String(mission_id || '').trim();
|
|
163
|
+
if (!id) return text('I need the id of the scout to stop it. km_list_lead_scouts will show you which ones are still running.');
|
|
164
|
+
const r = await call('POST', `/sourcing/missions/${encodeURIComponent(id)}/cancel`, {});
|
|
165
|
+
if (routeMissing(r)) return text(NOT_SUPPORTED);
|
|
166
|
+
return out(r);
|
|
167
|
+
},
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
// The two small paid lookups: one company, or one address.
|
|
171
|
+
|
|
172
|
+
server.tool(
|
|
173
|
+
'km_find_company_contacts',
|
|
174
|
+
[
|
|
175
|
+
'Find the decision-makers at ONE named business and get their real email addresses. Use it when the person has a specific company in mind: a venue they want to work with, a business that just opened nearby, a lead in the pipeline with a company name but no contact.',
|
|
176
|
+
'THIS COSTS REAL MONEY, though only a small fraction of a full scout: it looks up at most five people at that one business.',
|
|
177
|
+
'How to use it: call it FIRST without confirm_spend. That call is free, changes nothing, and comes back with the price. Tell the person, get a yes, then call again with confirm_spend set to true.',
|
|
178
|
+
'For finding many people across many companies at once, do not loop this tool. Use km_start_lead_scout instead, which is built for it and cheaper per contact.',
|
|
179
|
+
'What it will NOT do: it never writes to anyone. It hands back names, titles and addresses, and nothing more. If nobody reachable turns up, nothing is charged for the empty result.',
|
|
180
|
+
].join(' '),
|
|
181
|
+
{
|
|
182
|
+
company: z.string().optional().describe('The business name, as the person said it. For example "The Grand Ballroom" or "Dazzling Balloons".'),
|
|
183
|
+
website: z.string().optional().describe('The company website, if known. Helps when the name is a common one. Either company or website is required.'),
|
|
184
|
+
location: z.string().optional().describe('Town or city, if it helps tell this business apart from others with the same name.'),
|
|
185
|
+
confirm_spend: z
|
|
186
|
+
.boolean()
|
|
187
|
+
.optional()
|
|
188
|
+
.describe('Leave this out on the first call: you get the price back and nothing is charged. Set it to true ONLY after the person has agreed to the cost.'),
|
|
189
|
+
},
|
|
190
|
+
async (args) => {
|
|
191
|
+
const r = await call('POST', '/sourcing/company-contacts', args);
|
|
192
|
+
if (routeMissing(r)) return text(NOT_SUPPORTED);
|
|
193
|
+
return out(r);
|
|
194
|
+
},
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
server.tool(
|
|
198
|
+
'km_check_email_address',
|
|
199
|
+
[
|
|
200
|
+
'Check whether one email address is real and safe to write to, before anybody writes to it. Reach for it when someone reads an address off a business card or a website, when a contact in the pipeline looks doubtful, or before a message goes out to an address nobody has ever used.',
|
|
201
|
+
'This matters more than it sounds: writing to dead addresses is what gets a sender flagged as spam, and once that happens the good messages stop arriving too.',
|
|
202
|
+
'It COSTS REAL MONEY, but only a small amount for one address, and it charges nothing at all for an address that is obviously mistyped.',
|
|
203
|
+
'How to use it: call it FIRST without confirm_spend to see the price for free, then again with confirm_spend set to true. For a routine single check, telling the person the amount that came back and going ahead is usually enough.',
|
|
204
|
+
'It answers one of four ways: safe to write to, will bounce, risky, or could not tell. It also flags shared inboxes like info@ or sales@, which get far weaker replies than a named person.',
|
|
205
|
+
'What it will NOT do: it checks one address per call, it never writes to the address, and it never adds anybody to anything.',
|
|
206
|
+
].join(' '),
|
|
207
|
+
{
|
|
208
|
+
email: z.string().describe('The single email address to check.'),
|
|
209
|
+
confirm_spend: z
|
|
210
|
+
.boolean()
|
|
211
|
+
.optional()
|
|
212
|
+
.describe('Leave this out to see the price first without being charged. Set it to true to actually run the check.'),
|
|
213
|
+
},
|
|
214
|
+
async (args) => {
|
|
215
|
+
const r = await call('POST', '/sourcing/verify-email', args);
|
|
216
|
+
if (routeMissing(r)) return text(NOT_SUPPORTED);
|
|
217
|
+
return out(r);
|
|
218
|
+
},
|
|
219
|
+
);
|
|
220
|
+
}
|