@kivimedia/kmhub 2.0.0 → 2.9.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 +6 -5
- package/bin/kmhub.mjs +20 -7
- package/coach-book-output-guard.mjs +760 -0
- package/index.mjs +2 -0
- package/package.json +8 -3
- package/prompts/briefing.md +29 -0
- package/prompts/luxury.md +70 -0
- package/prompts/play.md +49 -0
- package/prompts/run.md +36 -0
- package/prompts/setup.md +33 -0
- package/prompts/vs-booked.md +46 -0
- package/prompts/what-can-you-do.md +40 -0
- package/prompts.mjs +109 -0
- package/read-only-tools.json +142 -0
- package/remote.mjs +815 -99
- package/tools/balloon-costing.mjs +80 -0
- package/tools/booking-equipment.mjs +110 -0
- package/tools/bridges.mjs +54 -0
- package/tools/calendar.mjs +9 -0
- package/tools/capabilities.mjs +155 -0
- package/tools/catalog.mjs +288 -0
- package/tools/clubs.mjs +176 -0
- package/tools/coach.mjs +771 -0
- package/tools/compare.mjs +76 -0
- package/tools/core.mjs +21 -0
- package/tools/crm.mjs +12 -3
- package/tools/dubsado.mjs +137 -0
- package/tools/exports.mjs +128 -0
- package/tools/fact-review.mjs +125 -0
- package/tools/flows.mjs +261 -0
- package/tools/forms.mjs +158 -0
- package/tools/gols.mjs +134 -0
- package/tools/hr.mjs +162 -0
- package/tools/knowledge.mjs +4 -3
- package/tools/marketing.mjs +396 -0
- package/tools/meta.mjs +2 -2
- package/tools/military.mjs +244 -0
- package/tools/outreach.mjs +27 -4
- package/tools/pending.mjs +122 -0
- package/tools/photos.mjs +140 -0
- package/tools/plays.mjs +1 -1
- package/tools/profile.mjs +118 -0
- package/tools/radar.mjs +173 -0
- package/tools/recurring-invoices.mjs +149 -0
- package/tools/reengage.mjs +434 -0
- package/tools/schedules.mjs +55 -0
- package/tools/setup.mjs +168 -0
- package/tools/sops-bridges.mjs +86 -0
- package/tools/sops.mjs +314 -0
- package/tools/sourcing.mjs +50 -2
- package/tools/strategy.mjs +146 -0
- package/tools/studio.mjs +132 -0
- package/tools/venueradar.mjs +151 -0
- package/tools/voice.mjs +134 -0
- package/tools.mjs +70 -12
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool family: reengage - the past-client engine. The people who already paid this
|
|
3
|
+
* business once, the occasions that bring them back, the sequences that reach them,
|
|
4
|
+
* the autopilot that runs it, and the drafts it produces.
|
|
5
|
+
*
|
|
6
|
+
* This is the WARM side of the house. outreach.mjs is the cold side, and the two
|
|
7
|
+
* have different safety lines on purpose:
|
|
8
|
+
*
|
|
9
|
+
* COLD (outreach.mjs) - strangers. Its header says in its own words "there is no
|
|
10
|
+
* approve route, no send route, no schedule". That is still true, and nothing in
|
|
11
|
+
* this file changes it. A cold message written by a model is still approved by a
|
|
12
|
+
* person in KM Hub before it goes anywhere.
|
|
13
|
+
*
|
|
14
|
+
* WARM (this file) - people with a won deal on this workspace, who have met the
|
|
15
|
+
* owner, paid an invoice, and had their party. Ziv has explicitly asked for full
|
|
16
|
+
* control of the re-engagement side from the terminal, so two tools exist here
|
|
17
|
+
* that do not exist anywhere else in the tool set:
|
|
18
|
+
*
|
|
19
|
+
* km_reengage_approve_draft - flips one re-engagement draft to approved.
|
|
20
|
+
* km_reengage_send - sends one composed re-engagement email now.
|
|
21
|
+
*
|
|
22
|
+
* Both are 'confirm' class writes: the API answers 409 with its own description of
|
|
23
|
+
* exactly what would happen plus a token, and the model has to put that sentence to
|
|
24
|
+
* the user and get a real yes before calling again. Both are confined to
|
|
25
|
+
* re-engagement work (a draft that is backed by a re-engagement enrollment, a
|
|
26
|
+
* client with a past event). Neither will approve or send a cold outreach draft,
|
|
27
|
+
* and the API refuses rather than quietly widening. Do not describe these tools to
|
|
28
|
+
* a user as "I can approve and send email now" - the honest sentence is "I can
|
|
29
|
+
* approve and send RE-ENGAGEMENT email to your past clients, with your yes each
|
|
30
|
+
* time".
|
|
31
|
+
*
|
|
32
|
+
* Write classes, declared on the API side and repeated here so the file is true on
|
|
33
|
+
* its own:
|
|
34
|
+
*
|
|
35
|
+
* free km_add_past_client files history, messages nobody
|
|
36
|
+
* km_reengage_draft (without queue) writes nothing at all
|
|
37
|
+
* confirm every other write in this file
|
|
38
|
+
*
|
|
39
|
+
* The family contract this file follows is documented in ./README.md.
|
|
40
|
+
*/
|
|
41
|
+
import { z } from 'zod';
|
|
42
|
+
|
|
43
|
+
export const FAMILY = 'reengage';
|
|
44
|
+
|
|
45
|
+
export const TOOLS = [
|
|
46
|
+
'km_past_clients',
|
|
47
|
+
'km_reengage_sequences',
|
|
48
|
+
'km_reengage_routing',
|
|
49
|
+
'km_reengage_scheduler',
|
|
50
|
+
'km_reengage_scheduler_runs',
|
|
51
|
+
'km_reengage_settings',
|
|
52
|
+
'km_reengage_stats',
|
|
53
|
+
'km_reengage_drafts',
|
|
54
|
+
'km_reengage_emails',
|
|
55
|
+
'km_add_past_client',
|
|
56
|
+
'km_reengage_sequence_start',
|
|
57
|
+
'km_reengage_set_routing',
|
|
58
|
+
'km_reengage_set_scheduler',
|
|
59
|
+
'km_reengage_scheduler_off',
|
|
60
|
+
'km_reengage_run_now',
|
|
61
|
+
'km_reengage_follow_up_switch',
|
|
62
|
+
'km_reengage_set_settings',
|
|
63
|
+
'km_reengage_draft',
|
|
64
|
+
'km_reengage_approve_draft',
|
|
65
|
+
'km_reengage_send',
|
|
66
|
+
'km_reengage_stop_enrollment',
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
// Re-engagement is the warm half of the same job the `outreach` profile exists to
|
|
70
|
+
// do: a session working the mail is as likely to be reaching last year's clients as
|
|
71
|
+
// this year's strangers. It always lands in `full` whatever it declares.
|
|
72
|
+
export const PROFILES = ['outreach'];
|
|
73
|
+
|
|
74
|
+
/** The routing types an org can tag a client with (lead-time.ts CUSTOMER_TYPES). */
|
|
75
|
+
const CUSTOMER_TYPES = ['New', 'Returning', 'Corporate', 'School', 'Nursery', 'Charity', 'Council', 'Other'];
|
|
76
|
+
|
|
77
|
+
/** The seven ways a sequence can start (lib/engines/outreach/sequence/start-modes.ts). */
|
|
78
|
+
const START_MODES = [
|
|
79
|
+
'manual',
|
|
80
|
+
'tag_added',
|
|
81
|
+
'event_anniversary',
|
|
82
|
+
'child_birthday',
|
|
83
|
+
'booking_created',
|
|
84
|
+
'days_before_event',
|
|
85
|
+
'days_after_event',
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
/** generate-reengagement-email's angles. */
|
|
89
|
+
const ANGLES = ['anniversary', 'birthday', 'general'];
|
|
90
|
+
|
|
91
|
+
const CONFIRM_HELP =
|
|
92
|
+
'Leave this out on the first call. This action needs an explicit yes from the person you are working with: '
|
|
93
|
+
+ 'KM Hub answers 409 with a plain description of exactly what would happen, plus a token. Show them that '
|
|
94
|
+
+ 'description in those words, wait for a real answer, and only then call again with the token and the SAME '
|
|
95
|
+
+ 'arguments. A yes for one action never authorises a different one.';
|
|
96
|
+
|
|
97
|
+
/** A fresh confirm_token field. A function rather than a shared instance so no two
|
|
98
|
+
* schemas can ever share a zod node. */
|
|
99
|
+
const confirmToken = () => z.string().optional().describe(CONFIRM_HELP);
|
|
100
|
+
|
|
101
|
+
/** Path-safe id. The API validates the uuid properly; this only stops a stray
|
|
102
|
+
* character reaching the query string. */
|
|
103
|
+
const seg = (v) => encodeURIComponent(String(v ?? '').trim());
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* @param {{ tool: Function }} server guarded registrar (see ./README.md)
|
|
107
|
+
* @param {(method: string, path: string, body?: any) => Promise<{ok:boolean,status:number,data:any}>} call
|
|
108
|
+
* @param {{ out: Function, qs: Function }} helpers shared response helpers from ../tools.mjs
|
|
109
|
+
*/
|
|
110
|
+
export function register(server, call, { out, qs }) {
|
|
111
|
+
// READS
|
|
112
|
+
|
|
113
|
+
server.tool(
|
|
114
|
+
'km_past_clients',
|
|
115
|
+
'The audience: every person who has already booked and paid this business, whose event has ALREADY HAPPENED, inside the years of history the workspace is set to keep. '
|
|
116
|
+
+ 'Each one comes back with their name, email, the event that anchors them, the customer type carried on their tags, how many times they have booked, whether a child birthday is on file, and the NEXT occasion that brings them back with the number of days to it. '
|
|
117
|
+
+ 'Reach for it when the user asks who is coming up, who they should be talking to, who booked them in a given year, or when you need the client_id behind a person before drafting or sending to them. Sort is soonest occasion first, which is almost always the order the question was really asked in. '
|
|
118
|
+
+ 'due_days is the sharp end: due_days=30 is "who is due in the next month", and that is the list worth acting on. '
|
|
119
|
+
+ 'A won deal with a FUTURE event date is NOT here and should not be: that person is waiting for their party, not remembering it. '
|
|
120
|
+
+ 'It pages and reports the true total, so never count from the rows in front of you, and if the answer says the history scan was capped, say so rather than presenting a slice as the whole book. '
|
|
121
|
+
+ 'Read only: nothing here enrols, drafts or sends.',
|
|
122
|
+
{
|
|
123
|
+
search: z.string().optional().describe('Match part of a name, company or email.'),
|
|
124
|
+
type: z.enum(CUSTOMER_TYPES).optional().describe('Narrow to one customer type, as tagged on the contact.'),
|
|
125
|
+
due_days: z.number().int().min(0).max(400).optional().describe('Only people whose next occasion is this many days away or fewer. 30 is the useful one.'),
|
|
126
|
+
limit: z.number().int().min(1).max(200).optional().describe('Default 50.'),
|
|
127
|
+
offset: z.number().int().min(0).optional().describe('Skip this many, for paging a long history.'),
|
|
128
|
+
},
|
|
129
|
+
async ({ search, type, due_days, limit, offset }) =>
|
|
130
|
+
out(await call('GET', `/reengage/past-clients${qs({ search, type, due_days, limit, offset })}`)),
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
server.tool(
|
|
134
|
+
'km_reengage_sequences',
|
|
135
|
+
'The sequence templates this workspace has: their names, how many steps each one carries, whether it is active, which one is the default, and above all HOW EACH ONE STARTS - on the anniversary of the event, on a child birthday, a set number of days before or after the event, as soon as someone books, when a tag is added, or only when something enrols them by hand. '
|
|
136
|
+
+ 'Reach for it before you talk about follow-up at all, because "the sequence" means nothing until you know which template and what starts it, and reach for it to get the template id that the routing, the autopilot and the start-mode tools all need. '
|
|
137
|
+
+ 'The start mode answers WHEN the first email is due once somebody is enrolled. It does NOT answer who enrols them: that is the occasion sweep, the recurring scheduler, a workflow, or a person. Blurring those two is how a user ends up believing a sequence is live when nothing is putting anybody into it. '
|
|
138
|
+
+ 'Read only.',
|
|
139
|
+
{},
|
|
140
|
+
async () => out(await call('GET', '/reengage/sequences')),
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
server.tool(
|
|
144
|
+
'km_reengage_routing',
|
|
145
|
+
'How each customer type is routed: which sequence template a Corporate client gets versus a School versus a Nursery, and the lead window on each one (the earliest and the latest number of weeks before the date that their first email may draft). '
|
|
146
|
+
+ 'Reach for it when the user asks what happens to a particular kind of client, why one group is being written to earlier than another, or before you change a window, so you can name the current setting back to them instead of overwriting a number they chose on purpose. '
|
|
147
|
+
+ 'A type with no row here falls back to the workspace-wide window in km_reengage_settings. Read only.',
|
|
148
|
+
{},
|
|
149
|
+
async () => out(await call('GET', '/reengage/routing')),
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
server.tool(
|
|
153
|
+
'km_reengage_scheduler',
|
|
154
|
+
'The autopilot: the standing per-customer-type assignments that put past clients into a sequence on a repeating cadence, each with how often it runs (every N weeks, or on a day of the month), which template it enrols into, its lead window, whether it is switched on, when it last ran and when it runs next. '
|
|
155
|
+
+ 'Reach for it when the user asks whether anything is running by itself, why people are being enrolled without them doing anything, or before you touch a cadence. '
|
|
156
|
+
+ 'This is the thing most likely to surprise somebody: an assignment left on quietly reaches real past clients every few weeks. If the user seems to have forgotten it exists, say what it is doing in plain numbers before they decide anything. '
|
|
157
|
+
+ 'Read only: km_reengage_set_scheduler changes one, km_reengage_scheduler_off stops one.',
|
|
158
|
+
{},
|
|
159
|
+
async () => out(await call('GET', '/reengage/scheduler')),
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
server.tool(
|
|
163
|
+
'km_reengage_scheduler_runs',
|
|
164
|
+
'The run history: every sweep that has actually fired, newest first, with what triggered it (the recurring tick or a Run Now), whether it was a dry run that changed nothing, how many contacts it processed, how many emails it generated, how many were auto approved, how many sent, how many people the autopilot enrolled, and the error if it failed. '
|
|
165
|
+
+ 'Reach for it as the FIRST answer to "is this thing even working", "did anything go out last night" and "why did nobody get an email" - a run history full of zeros, or empty, is the answer to all three and beats any amount of reasoning about the configuration. '
|
|
166
|
+
+ 'A dry run in this list proves the sweep ran and deliberately sent nothing; do not report it as a send. Read only.',
|
|
167
|
+
{
|
|
168
|
+
limit: z.number().int().min(1).max(100).optional().describe('Default 25, applied to each of the three run logs separately.'),
|
|
169
|
+
},
|
|
170
|
+
async ({ limit }) => out(await call('GET', `/reengage/scheduler/runs${qs({ limit })}`)),
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
server.tool(
|
|
174
|
+
'km_reengage_settings',
|
|
175
|
+
'The workspace-wide re-engagement settings in one read: how many years of past clients count as the audience, the default lead window (earliest and latest weeks before a date that a first email may draft) used by any customer type without its own, the grace period after a birthday inside which a late message is still allowed, whether the age a child is turning is tracked, AND whether the master follow-up switch is on at all. '
|
|
176
|
+
+ 'Reach for it before you explain ANY re-engagement behaviour, and reach for it first when the user says nothing is happening: the master switch being off is the single commonest cause, and it makes every other setting on this page irrelevant. '
|
|
177
|
+
+ 'Also reach for it before changing a number, so you can tell the user what it is now rather than replacing a deliberate choice with a guess. Read only.',
|
|
178
|
+
{},
|
|
179
|
+
async () => out(await call('GET', '/reengage/settings')),
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
server.tool(
|
|
183
|
+
'km_reengage_stats',
|
|
184
|
+
'How re-engagement mail is actually performing: how many tracked sends went out over the trailing window, how many were opened, how many were clicked, and the two rates. '
|
|
185
|
+
+ 'Reach for it when the user asks whether any of this is working, whether people are reading it, or whether it is worth continuing. '
|
|
186
|
+
+ 'Two honesties to carry into the answer. Only sends carrying a tracking token count, so this is the tracked slice and not necessarily every message. And an open is a pixel load: it undercounts anyone whose mail client blocks images and overcounts a preview pane, so a click is the number worth believing. '
|
|
187
|
+
+ 'This is the WHOLE-workspace transactional picture, not one campaign: km_outreach_campaign_stats is the cold campaign equivalent. Read only.',
|
|
188
|
+
{
|
|
189
|
+
days: z.number().int().min(1).max(365).optional().describe('Trailing window. Default 90, because re-engagement mail is infrequent and a 30 day window on it usually reads as nothing happening.'),
|
|
190
|
+
},
|
|
191
|
+
async ({ days }) => out(await call('GET', `/reengage/stats${qs({ days })}`)),
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
server.tool(
|
|
195
|
+
'km_reengage_drafts',
|
|
196
|
+
'The re-engagement messages waiting for a human: what the sweep and the manual drafting have written to past clients but not sent, newest first, with the subject, the opening of the body, who it is for and which occasion it is riding on. '
|
|
197
|
+
+ 'Reach for it when the user wants to work through the warm queue, asks what is waiting to go to past clients, or before you approve anything, so you are approving something you have actually read. '
|
|
198
|
+
+ 'This is the RE-ENGAGEMENT queue only. km_list_outreach_drafts is the cold outreach queue, and km_briefing ranks both against everything else waiting on the user, so do not answer "what needs me today" from this list alone. '
|
|
199
|
+
+ 'body_preview is the opening 240 characters only, so read the full wording with km_read_outreach_draft before approving anything. '
|
|
200
|
+
+ 'Read only. km_reengage_approve_draft is what releases one.',
|
|
201
|
+
{
|
|
202
|
+
client_id: z.string().optional().describe('UUID of one past client (see km_past_clients), for just their drafts.'),
|
|
203
|
+
status: z.enum(['draft', 'approved', 'scheduled', 'sent', 'cancelled', 'all']).optional().describe("Which stage to list. Default 'draft', the queue waiting on a human. Use 'sent' to answer \"did that one actually go out\", 'cancelled' for what was binned, or 'all' for the whole history."),
|
|
204
|
+
limit: z.number().int().min(1).max(100).optional().describe('Default 25.'),
|
|
205
|
+
offset: z.number().int().min(0).optional().describe('Skip this many, for paging a long queue.'),
|
|
206
|
+
},
|
|
207
|
+
async ({ client_id, status, limit, offset }) =>
|
|
208
|
+
out(await call('GET', `/reengage/drafts${qs({ client_id, status, limit, offset })}`)),
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
server.tool(
|
|
212
|
+
'km_reengage_emails',
|
|
213
|
+
'What re-engagement mail has actually LEFT the building: the real sends to past clients, who each went to, the subject, when it sent, and its delivery state. '
|
|
214
|
+
+ 'Reach for it when the user asks whether somebody has already been contacted, what was said to a particular past client, or wants the recent history before writing to them again. Checking here first is how you avoid mailing somebody twice in a fortnight and making the business look automated. '
|
|
215
|
+
+ 'It returns what was sent, not what is waiting: km_reengage_drafts is the queue and km_reengage_stats is the performance. Read only.',
|
|
216
|
+
{
|
|
217
|
+
client_id: z.string().optional().describe('UUID of one past client (see km_past_clients), for one person history.'),
|
|
218
|
+
from: z.string().optional().describe('Earliest send date, YYYY-MM-DD.'),
|
|
219
|
+
to: z.string().optional().describe('Latest send date, YYYY-MM-DD.'),
|
|
220
|
+
limit: z.number().int().min(1).max(100).optional().describe('Default 25.'),
|
|
221
|
+
},
|
|
222
|
+
async ({ client_id, from, to, limit }) =>
|
|
223
|
+
out(await call('GET', `/reengage/emails${qs({ client_id, from, to, limit })}`)),
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
// WRITES
|
|
227
|
+
|
|
228
|
+
server.tool(
|
|
229
|
+
'km_add_past_client',
|
|
230
|
+
'Put ONE past client on file: the person, plus the job they already had with this business, recorded as a won deal on the date it happened. That is what makes them part of the re-engagement audience, so this is how somebody who was booked before KM Hub existed becomes reachable by the anniversary and birthday sweeps. '
|
|
231
|
+
+ 'Reach for it when the user is reading names off an old diary, a spreadsheet or a phone, one at a time. For a whole file of them, the import in KM Hub is the right tool and this one is not: say so rather than looping this a hundred times. '
|
|
232
|
+
+ 'Search with km_past_clients or km_find_contact FIRST. A second copy of the same person splits their history, and the half nobody opens is the half with the child birthday on it. '
|
|
233
|
+
+ 'event_date must be a date that has ALREADY passed. A future date is an upcoming booking, not a past client, and km_create_booking is the tool for that. '
|
|
234
|
+
+ 'child_dob is worth asking for by name: it is the only thing that makes the birthday occasion possible, and it is the single highest-value field on this whole record for a business that does parties. '
|
|
235
|
+
+ 'This is a free-class write: it files history and messages nobody, so it needs no confirmation. It lands in the AI activity log where the owner can see and undo it. It does NOT enrol anyone, draft anything or send anything.',
|
|
236
|
+
{
|
|
237
|
+
client_name: z.string().describe('Full name of the client.'),
|
|
238
|
+
client_email: z.string().optional().describe('Without an email they can never be re-engaged, so ask for one rather than saving a bare name.'),
|
|
239
|
+
client_phone: z.string().optional(),
|
|
240
|
+
company: z.string().optional(),
|
|
241
|
+
event_date: z.string().describe('The date the event actually happened, YYYY-MM-DD. Must be in the past.'),
|
|
242
|
+
title: z.string().optional().describe('What the job was, e.g. "Mia\'s 5th birthday".'),
|
|
243
|
+
event_type: z.string().optional().describe('The kind of job, e.g. "Birthday party" or "Corporate launch". It becomes the title when no title is given, and it is context the re-engagement writer grounds its copy in.'),
|
|
244
|
+
child_name: z.string().optional(),
|
|
245
|
+
child_dob: z.string().optional().describe("The child's date of birth, YYYY-MM-DD. This is what makes a birthday occasion possible."),
|
|
246
|
+
customer_type: z.enum(CUSTOMER_TYPES).optional().describe('Tags the contact so the per-type routing and the autopilot can find them.'),
|
|
247
|
+
value_cents: z.number().int().min(0).optional().describe('What the job was worth, in CENTS.'),
|
|
248
|
+
},
|
|
249
|
+
async (args) => out(await call('POST', '/reengage/past-clients', args)),
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
server.tool(
|
|
253
|
+
'km_reengage_sequence_start',
|
|
254
|
+
'Set HOW a sequence starts: on the anniversary of their event, on a child birthday, a set number of days before or after the event, as soon as they book, when a tag is added, or only when something enrols them by hand. '
|
|
255
|
+
+ 'Read the template first with km_reengage_sequences and say the current setting back to the user, because this replaces a decision somebody already made. '
|
|
256
|
+
+ 'Each mode needs its own extra number and ignores the others: event_anniversary and child_birthday take lead_weeks (how far ahead the first email drafts), days_before_event and days_after_event take offset_days, tag_added takes tag, and manual and booking_created take neither. Sending the wrong one is how a sequence ends up starting on a date nobody expected. '
|
|
257
|
+
+ 'tag_added does one thing more: it also finds or creates the workflow that puts a tagged client into this sequence, and the answer names the workflow it used. Picking the same tag again reuses that workflow rather than stacking a second one that would enrol the same person twice. '
|
|
258
|
+
+ 'CONFIRM FIRST. This changes when real mail reaches real past clients: name the template, the mode and the number back to the user in a sentence and get a yes.',
|
|
259
|
+
{
|
|
260
|
+
template_id: z.string().describe('UUID of the sequence template (see km_reengage_sequences).'),
|
|
261
|
+
mode: z.enum(START_MODES).describe('How it starts. manual means nothing here enrols anybody; something else has to.'),
|
|
262
|
+
lead_weeks: z.number().int().min(0).max(52).optional().describe('Weeks BEFORE the date that the first email drafts. Only for event_anniversary and child_birthday.'),
|
|
263
|
+
offset_days: z.number().int().min(0).max(365).optional().describe('Days before or after the event, depending on the mode. Only for days_before_event and days_after_event.'),
|
|
264
|
+
tag: z.string().optional().describe('The tag that starts it. Only for tag_added, and required there.'),
|
|
265
|
+
confirm_token: confirmToken(),
|
|
266
|
+
},
|
|
267
|
+
async ({ template_id, mode, lead_weeks, offset_days, tag, confirm_token }) =>
|
|
268
|
+
out(await call('POST', `/reengage/sequences/${seg(template_id)}/start`, {
|
|
269
|
+
mode, lead_weeks, offset_days, tag, confirm_token,
|
|
270
|
+
})),
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
server.tool(
|
|
274
|
+
'km_reengage_set_routing',
|
|
275
|
+
'Route ONE customer type: which sequence a Corporate client gets versus a School versus a Nursery, and the lead window on that type (the earliest and the latest weeks before their date that a first email may draft). '
|
|
276
|
+
+ 'Reach for it when the user says something like "corporates should hear from us earlier" or "schools get the term-time sequence". Read km_reengage_routing first and tell them what it is now, because the number sitting there was almost certainly chosen on purpose. '
|
|
277
|
+
+ 'One type per call. max_lead_weeks is the start of the window and must not be below min_lead_weeks; the API clamps both to sane bounds rather than storing whatever arrives. '
|
|
278
|
+
+ 'A type left with no row here keeps falling back to the workspace default in km_reengage_settings, and clearing a row is not the same as switching that type off. '
|
|
279
|
+
+ 'CONFIRM FIRST: this changes when real mail reaches a whole group of real past clients.',
|
|
280
|
+
{
|
|
281
|
+
customer_type: z.enum(CUSTOMER_TYPES).describe('The type to route. It matches a tag on the contact.'),
|
|
282
|
+
template_id: z.string().optional().describe('UUID of the sequence this type should get (see km_reengage_sequences).'),
|
|
283
|
+
min_lead_weeks: z.number().int().min(0).max(52).optional().describe('Earliest weeks before the date that this type may be written to. A do-not-contact-too-early floor.'),
|
|
284
|
+
max_lead_weeks: z.number().int().min(0).max(52).optional().describe('How many weeks before the date the first email drafts. Must be at least min_lead_weeks.'),
|
|
285
|
+
confirm_token: confirmToken(),
|
|
286
|
+
},
|
|
287
|
+
async (args) => out(await call('POST', '/reengage/routing', args)),
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
server.tool(
|
|
291
|
+
'km_reengage_set_scheduler',
|
|
292
|
+
'Set up or change the AUTOPILOT for one customer type: a standing assignment that keeps finding past clients of that type and enrolling them into a sequence, every few weeks or on a day of the month, for as long as it is left on. '
|
|
293
|
+
+ 'This is the most consequential tool in the family. Everything else changes what a message says or when one draft is due; this decides that the business keeps reaching out to real people on its own, indefinitely, without anyone opening KM Hub. Read km_reengage_scheduler first and say out loud what is already running before you add another one. '
|
|
294
|
+
+ 'frequency_kind weeks uses every_weeks. frequency_kind monthly uses monthly_day. The lead window here is the same idea as the routing window: how far ahead of a date somebody may be pulled in. '
|
|
295
|
+
+ 'is_active=false parks the assignment without deleting it, which is the honest way to pause an experiment. km_reengage_scheduler_off does the same for one that already exists. '
|
|
296
|
+
+ 'CONFIRM FIRST, and be specific in the sentence you put to the user: name the type, the cadence, the sequence and the fact that it will keep going until someone stops it.',
|
|
297
|
+
{
|
|
298
|
+
customer_type: z.enum(CUSTOMER_TYPES).describe('The type this autopilot works through.'),
|
|
299
|
+
template_id: z.string().optional().describe('UUID of the sequence it enrols people into (see km_reengage_sequences). Leave it out to keep the sequence already on this assignment - changing only the cadence does not need it, and re-supplying an id you had to look up is how the wrong sequence gets pasted into a live autopilot.'),
|
|
300
|
+
frequency_kind: z.enum(['weeks', 'monthly']).optional().describe('Default weeks.'),
|
|
301
|
+
every_weeks: z.number().int().min(1).max(52).optional().describe('Run every N weeks. Only for frequency_kind weeks.'),
|
|
302
|
+
monthly_day: z.number().int().min(1).max(28).optional().describe('Day of the month to run. Only for frequency_kind monthly. Kept at 28 or below so every month actually has the day.'),
|
|
303
|
+
min_lead_weeks: z.number().int().min(0).max(52).optional().describe('Earliest weeks before a date that this autopilot may pull somebody in.'),
|
|
304
|
+
max_lead_weeks: z.number().int().min(0).max(52).optional().describe('How far ahead of a date it starts. Must be at least min_lead_weeks.'),
|
|
305
|
+
is_active: z.boolean().optional().describe('Default true. false parks it without losing the setup.'),
|
|
306
|
+
confirm_token: confirmToken(),
|
|
307
|
+
},
|
|
308
|
+
async (args) => out(await call('POST', '/reengage/scheduler', args)),
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
server.tool(
|
|
312
|
+
'km_reengage_scheduler_off',
|
|
313
|
+
'Switch off ONE autopilot assignment. The setup is kept, so this is reversible and nothing about the sequence, the routing or anyone already enrolled is lost: it simply stops finding new people. '
|
|
314
|
+
+ 'Reach for it when the user says a particular group is being contacted too often, or wants to stop one type without touching the rest. '
|
|
315
|
+
+ 'This is deliberately narrow. It does NOT stop the whole follow-up engine (that is km_reengage_follow_up_switch), it does NOT stop anybody who is already mid-sequence (that is km_reengage_stop_enrollment, one person at a time), and it does not unsend anything. If the user means "stop everything, now", say which of the three you are about to do and let them choose. '
|
|
316
|
+
+ 'Get the id from km_reengage_scheduler and name the assignment back to them. CONFIRM FIRST.',
|
|
317
|
+
{
|
|
318
|
+
assignment_id: z.string().describe('UUID of the assignment (see km_reengage_scheduler).'),
|
|
319
|
+
confirm_token: confirmToken(),
|
|
320
|
+
},
|
|
321
|
+
async ({ assignment_id, confirm_token }) =>
|
|
322
|
+
out(await call('POST', `/reengage/scheduler/${seg(assignment_id)}/off`, { confirm_token })),
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
server.tool(
|
|
326
|
+
'km_reengage_run_now',
|
|
327
|
+
'Run the follow-up sweep right now instead of waiting for the next tick: find everyone who is due, draft what is owed, and enrol whoever the autopilot would have enrolled. '
|
|
328
|
+
+ 'ALWAYS run it with dry_run=true first. A dry run mutates nothing and reports exactly who would be picked up and how many emails would be written, and it is the only way to find out that a lead window is wrong BEFORE a hundred past clients are drafted at. Show the user those numbers, then ask whether to run it for real. '
|
|
329
|
+
+ 'A real run can auto-approve and send where the workspace is configured to, so treat it as mail reaching people rather than as a refresh button. Use limit to keep a first real run small. '
|
|
330
|
+
+ 'It is not a fix for silence. If nothing has been going out, read km_reengage_settings (the master switch) and km_reengage_scheduler_runs first; firing this repeatedly against a switched-off engine just produces more empty runs. '
|
|
331
|
+
+ 'CONFIRM FIRST for a real run.',
|
|
332
|
+
{
|
|
333
|
+
dry_run: z.boolean().optional().describe('true previews and changes nothing. Do this first, every time.'),
|
|
334
|
+
campaign_id: z.string().optional().describe('UUID, to sweep one campaign only.'),
|
|
335
|
+
limit: z.number().int().min(1).max(500).optional().describe('Cap how many contacts this run processes. Worth setting on a first real run.'),
|
|
336
|
+
ignore_send_windows: z.boolean().optional().describe('Ignore the configured sending hours and days. Only with an explicit instruction: this is how mail arrives at 3am or on a Sunday.'),
|
|
337
|
+
confirm_token: confirmToken(),
|
|
338
|
+
},
|
|
339
|
+
async (args) => out(await call('POST', '/reengage/run-now', args)),
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
server.tool(
|
|
343
|
+
'km_reengage_follow_up_switch',
|
|
344
|
+
'The master switch for automatic follow-up on this workspace. Off means the recurring sweep stops entirely: no occasions found, no drafts written, no autopilot enrolments, nothing sent, for anybody. On means it resumes on its normal tick. '
|
|
345
|
+
+ 'Reach for OFF when the user wants everything to stop and means it - a bereavement, a business paused, a mistake they have just spotted. It is the biggest, bluntest control here and that is exactly why it is the right one in a moment like that. Say plainly that it stops all automatic follow-up rather than only the part they mentioned. '
|
|
346
|
+
+ 'Reach for ON only when they have asked for it. Turning this on is not a diagnostic step: it starts an engine that reaches real past clients, and "let me just switch it on and see" is not a thing to do to somebody else\'s client list. '
|
|
347
|
+
+ 'Check the current state with km_reengage_settings first, so you do not announce a change that changed nothing. CONFIRM FIRST, in both directions.',
|
|
348
|
+
{
|
|
349
|
+
on: z.boolean().describe('true resumes automatic follow-up. false stops all of it.'),
|
|
350
|
+
confirm_token: confirmToken(),
|
|
351
|
+
},
|
|
352
|
+
async ({ on, confirm_token }) => out(await call('POST', '/reengage/follow-up-switch', { on, confirm_token })),
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
server.tool(
|
|
356
|
+
'km_reengage_set_settings',
|
|
357
|
+
'Change the workspace-wide re-engagement settings: how many years of past clients count as the audience, the default lead window used by any customer type without its own, the grace period after a birthday inside which a late message is still allowed, and whether the age a child is turning is tracked. '
|
|
358
|
+
+ 'Send only the fields you are changing. The rest of the workspace settings are left exactly as they are, and the API merges rather than replacing, so nothing unrelated is lost. Values are clamped to the same bounds the app uses (years 1 to 10, weeks 0 to 52, and the maximum lead never below the minimum), so a number that comes back different from the one you sent was out of range and worth telling the user about. '
|
|
359
|
+
+ 'Read km_reengage_settings first. years_back in particular is not a small dial: raising it widens the audience to people who have not heard from this business in years, and lowering it silently drops people the user may still count as clients. Say what the change does to the size of the audience, not just what the number becomes. '
|
|
360
|
+
+ 'This does NOT turn follow-up on or off. That is km_reengage_follow_up_switch. CONFIRM FIRST.',
|
|
361
|
+
{
|
|
362
|
+
years_back: z.number().int().min(1).max(10).optional().describe('Years of past-client history that count as the audience.'),
|
|
363
|
+
earliest_weeks_before: z.number().int().min(0).max(52).optional().describe('Workspace-wide earliest weeks before a date that anyone may be written to.'),
|
|
364
|
+
start_weeks_before: z.number().int().min(0).max(52).optional().describe('Workspace-wide weeks before a date that the first email drafts. Never below earliest_weeks_before.'),
|
|
365
|
+
post_birthday_grace_days: z.number().int().min(0).max(30).optional().describe('Days after a birthday inside which a late message is still allowed to go. Past that it is skipped rather than arriving as a stale "happy birthday".'),
|
|
366
|
+
birthday_age_tracking: z.boolean().optional().describe('Whether the age a child is turning is tracked and usable in copy.'),
|
|
367
|
+
confirm_token: confirmToken(),
|
|
368
|
+
},
|
|
369
|
+
async (args) => out(await call('POST', '/reengage/settings', args)),
|
|
370
|
+
);
|
|
371
|
+
|
|
372
|
+
server.tool(
|
|
373
|
+
'km_reengage_draft',
|
|
374
|
+
'Write a re-engagement email for ONE past client, grounded in their real history: the event they had, when it was, the child on the record and the age they are turning. Returns the subject, the body and the angle it chose. '
|
|
375
|
+
+ 'Reach for it when the user says "write to her", "what would we say to him", or wants to see what the sweep would produce for somebody before trusting it with a hundred people. Get the client_id from km_past_clients. '
|
|
376
|
+
+ 'By default this writes NOTHING anywhere: it hands the words back for the user to read, and it is free to call, so show them the draft rather than describing it. With queue=true it also files the message in the approval queue, where a person releases it. '
|
|
377
|
+
+ 'Choose the angle honestly. birthday only when there is a child date of birth on the record, anniversary when the event date is the hook, general otherwise. Left out, the API picks from what the record actually carries. '
|
|
378
|
+
+ 'It never sends. km_reengage_send is the tool that does that, and it asks first.',
|
|
379
|
+
{
|
|
380
|
+
client_id: z.string().describe('UUID of the past client (see km_past_clients).'),
|
|
381
|
+
angle: z.enum(ANGLES).optional().describe('The hook. Leave it out to let the record decide.'),
|
|
382
|
+
tone: z.string().optional().describe('How it should read, e.g. "warm and short", "playful". The workspace brand voice is applied either way.'),
|
|
383
|
+
queue: z.boolean().optional().describe('true also files it in the approval queue for a person to release. Left out, nothing is written anywhere.'),
|
|
384
|
+
},
|
|
385
|
+
async (args) => out(await call('POST', '/reengage/draft', args)),
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
server.tool(
|
|
389
|
+
'km_reengage_approve_draft',
|
|
390
|
+
'Approve ONE re-engagement draft so the sweep sends it. This acts on a real person: after this the message leaves the building on the next sweep, arrives in somebody\'s inbox with the business owner\'s name on it, and cannot be recalled. '
|
|
391
|
+
+ 'Read the FULL wording with km_read_outreach_draft first and put the actual subject and body in front of the user. km_reengage_drafts only carries the opening 240 characters, so approving from that list alone is approving something you have not read. '
|
|
392
|
+
+ 'This exists because the owner asked for it, and it is deliberately narrow: it only approves RE-ENGAGEMENT drafts, the warm mail to past clients who have already paid this business. It will not approve a cold outreach draft, and the API refuses rather than stretching. Cold approval stays a human act in KM Hub, which is the standing policy and not something to work around. '
|
|
393
|
+
+ 'CONFIRM FIRST, every single time. One yes approves one message: it never carries to the next one, and "approve the rest of them" is a separate yes for each, or a job for the user in KM Hub. '
|
|
394
|
+
+ 'There is no unapprove here. If they change their mind after this, the honest answer is that the message is on its way.',
|
|
395
|
+
{
|
|
396
|
+
draft_id: z.string().describe('UUID of the re-engagement draft (see km_reengage_drafts).'),
|
|
397
|
+
confirm_token: confirmToken(),
|
|
398
|
+
},
|
|
399
|
+
async ({ draft_id, confirm_token }) =>
|
|
400
|
+
out(await call('POST', `/reengage/drafts/${seg(draft_id)}/approve`, { confirm_token })),
|
|
401
|
+
);
|
|
402
|
+
|
|
403
|
+
server.tool(
|
|
404
|
+
'km_reengage_send',
|
|
405
|
+
'Send a re-engagement email to ONE past client NOW, from this workspace, and record it against them. This puts a real message in a real person\'s inbox the moment it succeeds. There is no queue, no delay and no undo. '
|
|
406
|
+
+ 'Show the user the exact subject and the exact body you are about to send, in full, and get a yes on THAT text. Not a summary of it, not "the draft we discussed". Write the words with km_reengage_draft first if they do not already exist. '
|
|
407
|
+
+ 'Check km_reengage_emails for what this person has already been sent. Two warm emails in a fortnight is how a business that prides itself on remembering people starts sounding like a mailing list. '
|
|
408
|
+
+ 'This exists because the owner asked for full control of the re-engagement side from the terminal, and it stops there: it sends to past clients only, and it will not send cold outreach. That mail is still written as a draft and approved by a person in KM Hub. Do not describe this tool to a user as general "I can send email" - say re-engagement, and say you will ask each time. '
|
|
409
|
+
+ 'CONFIRM FIRST, every time, for every recipient. A yes for one person is never a yes for the next.',
|
|
410
|
+
{
|
|
411
|
+
client_id: z.string().describe('UUID of the past client (see km_past_clients).'),
|
|
412
|
+
subject: z.string().describe('The exact subject line that will be sent.'),
|
|
413
|
+
body: z.string().describe('The exact message text that will be sent. The full message, never a summary of it.'),
|
|
414
|
+
confirm_token: confirmToken(),
|
|
415
|
+
},
|
|
416
|
+
async (args) => out(await call('POST', '/reengage/send', args)),
|
|
417
|
+
);
|
|
418
|
+
|
|
419
|
+
server.tool(
|
|
420
|
+
'km_reengage_stop_enrollment',
|
|
421
|
+
'Stop ONE person\'s sequence. Nothing further is drafted or sent to them on it, and the rest of the workspace carries on untouched. '
|
|
422
|
+
+ 'Reach for it the instant a user says a particular client should be left alone: they replied, they complained, they booked already, they had a bereavement, they asked to be taken off. This is the right tool for one person and it should be used quickly, because the alternative is another automated email landing on somebody who has just told you to stop. '
|
|
423
|
+
+ 'Get the enrollment id from km_past_clients, where a live enrollment is carried on the row, and name the person back to the user so you are certain you are stopping the right one. '
|
|
424
|
+
+ 'It is one person only. To stop a whole type, use km_reengage_scheduler_off; to stop everything, km_reengage_follow_up_switch. Stopping does not delete their history, does not remove them from the audience, and does not recall anything already sent. '
|
|
425
|
+
+ 'CONFIRM FIRST, and keep the sentence honest: this ends their follow-up rather than pausing it, so putting them back means enrolling them again.',
|
|
426
|
+
{
|
|
427
|
+
enrollment_id: z.string().describe('UUID of the enrollment (see km_past_clients, on the row for that person).'),
|
|
428
|
+
reason: z.string().optional().describe("Why they are being stopped, in a few words. It is stored on the enrollment and read back in KM Hub later, so use the user's own reason - 'she replied', 'they booked already', 'bereavement' - rather than a generic one."),
|
|
429
|
+
confirm_token: confirmToken(),
|
|
430
|
+
},
|
|
431
|
+
async ({ enrollment_id, reason, confirm_token }) =>
|
|
432
|
+
out(await call('POST', `/reengage/enrollments/${seg(enrollment_id)}/stop`, { reason, confirm_token })),
|
|
433
|
+
);
|
|
434
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool family: schedules - what runs on its own, and whether it actually is.
|
|
3
|
+
*
|
|
4
|
+
* Workstream D, the half that is safe today. D1's writing scheduler was rejected by
|
|
5
|
+
* three independent reviews against the B8 write policy: a scheduled run composes
|
|
6
|
+
* its request body fresh at run time, so no confirm token can exist for it in
|
|
7
|
+
* advance, and "scheduling is the consent" can only be built as a branch that skips
|
|
8
|
+
* verification entirely.
|
|
9
|
+
*
|
|
10
|
+
* 🚨 What ships is the thing whose ABSENCE is dangerous. A recurring job that fails
|
|
11
|
+
* past its retry limit is set failed and nothing re-queues it and nothing reports
|
|
12
|
+
* it. The schedule is dead permanently and the client finds out weeks later. Worse,
|
|
13
|
+
* "ran and found nothing" and "has not run since April" look identical from the
|
|
14
|
+
* outside. This makes them distinguishable.
|
|
15
|
+
*
|
|
16
|
+
* The family contract this file follows is documented in ./README.md.
|
|
17
|
+
*/
|
|
18
|
+
export const FAMILY = 'schedules';
|
|
19
|
+
|
|
20
|
+
export const TOOLS = ['km_list_schedules'];
|
|
21
|
+
|
|
22
|
+
export const PROFILES = ['*'];
|
|
23
|
+
|
|
24
|
+
function routeMissing(r) {
|
|
25
|
+
return r.status === 404 || r.status === 405 || r.status === 501;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const NOT_SUPPORTED =
|
|
29
|
+
'Your KM Hub does not expose scheduling to the terminal yet. That is not a fault: the workspace is fine and every ' +
|
|
30
|
+
'other tool works as normal. Recurring work is configured in the web app at https://hub.kivimedia.co.';
|
|
31
|
+
|
|
32
|
+
export function register(server, call, { out, text }) {
|
|
33
|
+
server.tool(
|
|
34
|
+
'km_list_schedules',
|
|
35
|
+
'Everything in this workspace set to run on its own - the morning sweep, sequence sweeps, scheduled actions and ' +
|
|
36
|
+
'the rest - with when each is next due, when it last finished, and whether it is actually healthy. ' +
|
|
37
|
+
'Call it whenever the user asks what runs automatically, why something stopped arriving, or whether anything ' +
|
|
38
|
+
'is set up. Also call it before telling anybody that nothing happens on its own here. ' +
|
|
39
|
+
'🚨 LEAD WITH `problems`, and specifically with `dead_and_will_not_retry`. A recurring job that failed past its ' +
|
|
40
|
+
'retry limit is stopped PERMANENTLY: nothing re-queues it and no other screen in the product mentions it. The ' +
|
|
41
|
+
'client will not discover it on their own, because a schedule that silently stopped looks exactly like a quiet ' +
|
|
42
|
+
'week. Telling them is often the single most valuable thing in a session. ' +
|
|
43
|
+
'🚨 `overdue_hours` above about a day usually means the WORKER is not running, not that the schedule is wrong. ' +
|
|
44
|
+
'Say which you think it is rather than reporting a number and moving on. ' +
|
|
45
|
+
'Nothing here creates, pauses, restarts or runs a schedule. That happens in KM Hub, deliberately: a scheduled ' +
|
|
46
|
+
'run acts with nobody present, so it cannot give the confirmation that a money-spending or client-visible ' +
|
|
47
|
+
'action requires. Read only.',
|
|
48
|
+
{},
|
|
49
|
+
async () => {
|
|
50
|
+
const r = await call('GET', '/schedules');
|
|
51
|
+
if (routeMissing(r)) return text(NOT_SUPPORTED, true);
|
|
52
|
+
return out(r);
|
|
53
|
+
},
|
|
54
|
+
);
|
|
55
|
+
}
|