@kivimedia/kmhub 2.0.0 → 2.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +6 -5
  2. package/bin/kmhub.mjs +20 -7
  3. package/coach-book-output-guard.mjs +760 -0
  4. package/index.mjs +2 -0
  5. package/package.json +8 -3
  6. package/prompts/briefing.md +29 -0
  7. package/prompts/luxury.md +70 -0
  8. package/prompts/play.md +49 -0
  9. package/prompts/run.md +36 -0
  10. package/prompts/setup.md +33 -0
  11. package/prompts/vs-booked.md +46 -0
  12. package/prompts/what-can-you-do.md +40 -0
  13. package/prompts.mjs +110 -0
  14. package/read-only-tools.json +142 -0
  15. package/remote.mjs +815 -99
  16. package/tools/balloon-costing.mjs +80 -0
  17. package/tools/booking-equipment.mjs +110 -0
  18. package/tools/bridges.mjs +54 -0
  19. package/tools/calendar.mjs +9 -0
  20. package/tools/capabilities.mjs +155 -0
  21. package/tools/catalog.mjs +288 -0
  22. package/tools/clubs.mjs +176 -0
  23. package/tools/coach.mjs +771 -0
  24. package/tools/compare.mjs +76 -0
  25. package/tools/core.mjs +21 -0
  26. package/tools/crm.mjs +12 -3
  27. package/tools/dubsado.mjs +137 -0
  28. package/tools/exports.mjs +128 -0
  29. package/tools/fact-review.mjs +125 -0
  30. package/tools/flows.mjs +261 -0
  31. package/tools/forms.mjs +158 -0
  32. package/tools/gols.mjs +134 -0
  33. package/tools/hr.mjs +162 -0
  34. package/tools/knowledge.mjs +4 -3
  35. package/tools/marketing.mjs +396 -0
  36. package/tools/meta.mjs +2 -2
  37. package/tools/military.mjs +244 -0
  38. package/tools/outreach.mjs +27 -4
  39. package/tools/pending.mjs +122 -0
  40. package/tools/photos.mjs +140 -0
  41. package/tools/plays.mjs +1 -1
  42. package/tools/profile.mjs +118 -0
  43. package/tools/radar.mjs +173 -0
  44. package/tools/recurring-invoices.mjs +149 -0
  45. package/tools/reengage.mjs +434 -0
  46. package/tools/schedules.mjs +55 -0
  47. package/tools/setup.mjs +168 -0
  48. package/tools/sops-bridges.mjs +86 -0
  49. package/tools/sops.mjs +314 -0
  50. package/tools/sourcing.mjs +50 -2
  51. package/tools/strategy.mjs +146 -0
  52. package/tools/studio.mjs +132 -0
  53. package/tools/venueradar.mjs +151 -0
  54. package/tools/voice.mjs +134 -0
  55. package/tools.mjs +70 -12
@@ -0,0 +1,261 @@
1
+ /**
2
+ * Tool family: flows - the automation that runs whether anybody is watching.
3
+ *
4
+ * Workstreams B2, B3 and B4. Sequences, workflows, enquiry capture, gambits,
5
+ * forms, questionnaires, SMS, spend and job costing all ran invisibly to the
6
+ * terminal.
7
+ *
8
+ * 🚨 The worst gap here was collision. A connector that cannot see a running
9
+ * sequence will happily propose writing to somebody it is already emailing, and two
10
+ * messages from one business on one day is how a good prospect is lost.
11
+ *
12
+ * 🚨 READ ONLY, except km_send_sms: one text the owner approved word for word, gated
13
+ * server-side by a confirm token. Enrolling, pausing and firing automation act on real
14
+ * people and stay in the web app.
15
+ *
16
+ * The family contract this file follows is documented in ./README.md.
17
+ */
18
+ import { z } from 'zod';
19
+
20
+ export const FAMILY = 'flows';
21
+
22
+ export const TOOLS = [
23
+ 'km_list_sequences',
24
+ 'km_list_workflows',
25
+ 'km_inquiry_sources',
26
+ 'km_list_gambits',
27
+ 'km_list_forms',
28
+ 'km_list_questionnaires',
29
+ 'km_list_sms',
30
+ 'km_send_sms',
31
+ 'km_list_expenses',
32
+ 'km_list_job_costs',
33
+ 'km_list_rentals',
34
+ 'km_music_settings',
35
+ ];
36
+
37
+ export const PROFILES = ['outreach', 'money'];
38
+
39
+ function routeMissing(r) {
40
+ return r.status === 404 || r.status === 405 || r.status === 501;
41
+ }
42
+
43
+ const NOT_SUPPORTED =
44
+ 'Your KM Hub does not expose this surface to the terminal yet. That is not a fault: the workspace is fine and every ' +
45
+ 'other tool works as normal. It is there in the web app at https://hub.kivimedia.co.';
46
+
47
+ export function register(server, call, { out, text, qs }) {
48
+ server.tool(
49
+ 'km_list_sequences',
50
+ 'Who is enrolled in an automated sequence, which step they are on, when the next message goes, and why anybody ' +
51
+ 'stopped. ' +
52
+ '🚨 CALL THIS BEFORE DRAFTING TO ANYONE. `actively_enrolled` is the number that matters: those contacts are ' +
53
+ 'already being messaged automatically. Writing to somebody a sequence is mid-way through means two messages from ' +
54
+ 'one business on one day, which reads as chaotic and loses good prospects. It is the single most valuable check ' +
55
+ 'in this family and it is invisible everywhere else. ' +
56
+ '`stop_reason` is worth reading too: the same reason recurring is a sequence problem, not bad luck. Read only.',
57
+ {
58
+ status: z.string().optional().describe('Narrow to one state, for example active, stopped or completed.'),
59
+ limit: z.number().int().min(1).max(200).optional().describe('How many enrolments, newest first. Default 50.'),
60
+ },
61
+ async ({ status, limit }) => {
62
+ const r = await call('GET', `/flows/sequences${qs({ status, limit })}`);
63
+ if (routeMissing(r)) return text(NOT_SUPPORTED, true);
64
+ return out(r);
65
+ },
66
+ );
67
+
68
+ server.tool(
69
+ 'km_list_workflows',
70
+ 'The automations configured on this workspace, what triggers each one, and whether it is switched on. ' +
71
+ 'Use it before telling anybody that nothing will happen automatically, and when something occurred that nobody ' +
72
+ 'remembers doing. An active workflow acts without a human present, so "I did not send that" and "nothing was ' +
73
+ 'sent" are different claims and this tool separates them. ' +
74
+ 'Step definitions are not returned: what matters is what fires and whether it is on. Read only.',
75
+ {},
76
+ async () => {
77
+ const r = await call('GET', '/flows/workflows');
78
+ if (routeMissing(r)) return text(NOT_SUPPORTED, true);
79
+ return out(r);
80
+ },
81
+ );
82
+
83
+ server.tool(
84
+ 'km_inquiry_sources',
85
+ 'Where enquiries actually came from, counted by source and by status. ' +
86
+ 'Use it whenever the user asks where their work comes from, or before recommending they spend more effort ' +
87
+ 'anywhere. 🚨 The answer is very often different from what the owner believes, and that gap is one of the more ' +
88
+ 'valuable things you can show them. Read only.',
89
+ {
90
+ limit: z.number().int().min(1).max(200).optional().describe('How many enquiries to count over, newest first. Default 50.'),
91
+ },
92
+ async ({ limit }) => {
93
+ const r = await call('GET', `/flows/inquiries${qs({ limit })}`);
94
+ if (routeMissing(r)) return text(NOT_SUPPORTED, true);
95
+ return out(r);
96
+ },
97
+ );
98
+
99
+ server.tool(
100
+ 'km_list_gambits',
101
+ 'The lead gambits configured on this workspace. Use it when the user mentions a gambit by name or asks what is set ' +
102
+ 'up. Configuring one happens in KM Hub. Read only.',
103
+ {},
104
+ async () => {
105
+ const r = await call('GET', '/flows/gambits');
106
+ if (routeMissing(r)) return text(NOT_SUPPORTED, true);
107
+ return out(r);
108
+ },
109
+ );
110
+
111
+ server.tool(
112
+ 'km_list_forms',
113
+ 'The booking and lead forms on this workspace and which are actually live. ' +
114
+ 'A live form is a public front door, so `live_count` of zero is worth saying out loud: it means there is no route ' +
115
+ 'for somebody to enquire through, however good the marketing is. Use it before advising anything about capturing ' +
116
+ 'leads. Field configuration is not returned; editing happens in KM Hub. Read only.',
117
+ {},
118
+ async () => {
119
+ const r = await call('GET', '/flows/forms');
120
+ if (routeMissing(r)) return text(NOT_SUPPORTED, true);
121
+ return out(r);
122
+ },
123
+ );
124
+
125
+ server.tool(
126
+ 'km_list_questionnaires',
127
+ 'Questionnaires sent to clients, and whether they were viewed and answered. ' +
128
+ '🚨 `sent_but_unanswered` is the number to lead with. Each one is event detail nobody has, and missing detail ' +
129
+ 'does not stay quiet: it surfaces on the day rather than politely in advance. Answers themselves live in KM Hub. ' +
130
+ 'Read only.',
131
+ {
132
+ limit: z.number().int().min(1).max(200).optional().describe('How many, newest first. Default 50.'),
133
+ },
134
+ async ({ limit }) => {
135
+ const r = await call('GET', `/flows/questionnaires${qs({ limit })}`);
136
+ if (routeMissing(r)) return text(NOT_SUPPORTED, true);
137
+ return out(r);
138
+ },
139
+ );
140
+
141
+ server.tool(
142
+ 'km_list_sms',
143
+ 'Text messages in and out of the business line, grouped into threads, with who each is with, direction and the ' +
144
+ 'words. Texts the owner typed in the Quo app on their phone are included, so a thread is the whole exchange. ' +
145
+ 'Use it when the user asks what came in by text, what a client texted, or whether somebody was actually reached. ' +
146
+ 'Without arguments you get the latest texts plus `threads`, where `awaiting_your_reply: true` means the customer ' +
147
+ 'had the last word. To read one conversation in order, pass `thread_of` with any message id from it, or ' +
148
+ '`client_id`. ' +
149
+ '🚨 An inbound text nobody answered is the fastest-decaying message a business gets: people who text expect a ' +
150
+ 'text back, and an hour is already late. Phone numbers are deliberately not returned. To answer, use km_send_sms.',
151
+ {
152
+ limit: z.number().int().min(1).max(200).optional().describe('How many messages. Default 50.'),
153
+ thread_of: z.string().optional().describe('Any message id in a conversation: returns that whole thread, oldest first.'),
154
+ client_id: z.string().optional().describe('A client id: returns the texts linked to that client, oldest first.'),
155
+ },
156
+ async ({ limit, thread_of, client_id }) => {
157
+ const r = await call('GET', `/flows/sms${qs({ limit, thread_of, client_id })}`);
158
+ if (routeMissing(r)) return text(NOT_SUPPORTED, true);
159
+ return out(r);
160
+ },
161
+ );
162
+
163
+ server.tool(
164
+ 'km_send_sms',
165
+ 'Send ONE text message to a customer from the business line. It is a real text on their phone and cannot be ' +
166
+ 'unsent, and it is logged on the client\'s timeline and in km_list_sms automatically. ' +
167
+ 'Pick the recipient ONE way: `reply_to_message_id` (a message id from km_list_sms - answers that thread without ' +
168
+ 'the number ever passing through this chat), `client_id`, or `to` (a phone number the user gave you). ' +
169
+ '🚨 Write the text, show the user the exact words, and send only the words they approved. The first call returns ' +
170
+ 'a confirmation carrying a sentence written by the server about what will happen, plus a token. Read that ' +
171
+ 'sentence to the user as it is, get a yes to THIS text, then call again with the same arguments and ' +
172
+ 'confirm_token. Any change to the words needs a new confirmation. Keep it short and human: a text, not an email.',
173
+ {
174
+ text: z.string().describe('The exact words to send, as the user approved them.'),
175
+ reply_to_message_id: z.string().optional().describe('Answer this thread: a message id from km_list_sms.'),
176
+ client_id: z.string().optional().describe('Text this client at the phone number on their record.'),
177
+ to: z.string().optional().describe('A phone number the user gave you, when there is no thread or client.'),
178
+ confirm_token: z
179
+ .string()
180
+ .optional()
181
+ .describe('Only on the second call, after the user read the confirmation sentence and said yes. Never invent one.'),
182
+ },
183
+ async ({ text: words, reply_to_message_id, client_id, to, confirm_token }) => {
184
+ const body = { text: words };
185
+ if (reply_to_message_id) body.reply_to_message_id = String(reply_to_message_id);
186
+ if (client_id) body.client_id = String(client_id);
187
+ if (to) body.to = String(to);
188
+ if (confirm_token) body.confirm_token = String(confirm_token);
189
+ const r = await call('POST', '/flows/sms/send', body);
190
+ if (routeMissing(r)) return text(NOT_SUPPORTED, true);
191
+ return out(r);
192
+ },
193
+ );
194
+
195
+ server.tool(
196
+ 'km_list_expenses',
197
+ 'What has been spent in a window, split into what is billable to a client and what the business absorbs, with ' +
198
+ 'totals by category. Defaults to the last 90 days. ' +
199
+ '🚨 `absorbed` is the number that decides whether a busy month was a profitable one, and it is the one nobody ' +
200
+ 'looks at. The money tools answer what came IN; without this you can describe a healthy-looking month with no idea ' +
201
+ 'whether any of it was kept. All amounts are in CENTS. Read only.',
202
+ {
203
+ from: z.string().optional().describe('Start date, YYYY-MM-DD. Default 90 days ago.'),
204
+ to: z.string().optional().describe('End date, YYYY-MM-DD. Default tomorrow.'),
205
+ limit: z.number().int().min(1).max(500).optional().describe('How many entries. Default 100. Check `truncated` before trusting a total.'),
206
+ },
207
+ async ({ from, to, limit }) => {
208
+ const r = await call('GET', `/ops/expenses${qs({ from, to, limit })}`);
209
+ if (routeMissing(r)) return text(NOT_SUPPORTED, true);
210
+ return out(r);
211
+ },
212
+ );
213
+
214
+ server.tool(
215
+ 'km_list_job_costs',
216
+ 'Jobs that have been costed, with the overhead and target profit assumptions behind each. ' +
217
+ '🚨 Those percentages are ASSUMPTIONS the owner set when costing the job, NOT margin achieved. Describing an ' +
218
+ 'intended margin as a delivered one is the most misleading thing you could do with this data, so say "targeted" ' +
219
+ 'rather than "made". Line-item detail lives in KM Hub. Read only.',
220
+ {
221
+ limit: z.number().int().min(1).max(100).optional().describe('How many costed jobs, newest first. Default 25.'),
222
+ },
223
+ async ({ limit }) => {
224
+ const r = await call('GET', `/ops/job-costing${qs({ limit })}`);
225
+ if (routeMissing(r)) return text(NOT_SUPPORTED, true);
226
+ return out(r);
227
+ },
228
+ );
229
+
230
+ server.tool(
231
+ 'km_list_rentals',
232
+ 'Stock holds and the rental lines sitting on proposals: what is held, for whom, between which dates, and what is ' +
233
+ 'reserved. ' +
234
+ '🚨 A HOLD is stock promised to somebody who has NOT committed. An expiring hold that nobody chases ' +
235
+ 'silently releases kit a buyer still believes is theirs, which is worse than never holding it, so ' +
236
+ '`holds_with_an_expiry` is worth raising. Amounts are in CENTS. Releasing or extending a hold happens in KM Hub. ' +
237
+ 'Read only.',
238
+ {
239
+ limit: z.number().int().min(1).max(200).optional().describe('How many holds and rental lines. Default 50.'),
240
+ },
241
+ async ({ limit }) => {
242
+ const r = await call('GET', `/ops/rentals${qs({ limit })}`);
243
+ if (routeMissing(r)) return text(NOT_SUPPORTED, true);
244
+ return out(r);
245
+ },
246
+ );
247
+
248
+ server.tool(
249
+ 'km_music_settings',
250
+ 'What clients may request on the public music page: the per-event request limit, whether off-catalogue requests are ' +
251
+ 'allowed, whether remaining requests are shown, and whether Spotify is connected. ' +
252
+ 'Use it before advising anything about music requests for an event. Off-catalogue requests are the ones that create ' +
253
+ 'work on the day, so whether they are allowed matters more than the limit itself. Read only.',
254
+ {},
255
+ async () => {
256
+ const r = await call('GET', '/ops/music');
257
+ if (routeMissing(r)) return text(NOT_SUPPORTED, true);
258
+ return out(r);
259
+ },
260
+ );
261
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * forms.mjs - reading and editing the questions on a public booking form.
3
+ *
4
+ * WHY THIS FAMILY EXISTS. km_list_forms in the flows family says which forms are
5
+ * live and then stops: "Field configuration is not returned; editing happens in
6
+ * KM Hub." On 27-Aug-2026 Mark Fuller found his live booking form asking every
7
+ * customer for a phone number twice, tried to fix it from the terminal, and could
8
+ * not. There was no tool that could even SEE a form's fields. Worse, the web app
9
+ * has no editor for the built-in required flags at all, so for half of this there
10
+ * was nowhere to go.
11
+ *
12
+ * 🚨 A BOOKING FORM IS A PAGE ON THE OPEN INTERNET. Every other write reachable
13
+ * from the terminal changes something internal that a person can quietly correct.
14
+ * This one changes what a stranger is asked. That is why the edit is a confirmed
15
+ * write: KM Hub answers 409 with a sentence naming the questions being changed,
16
+ * and nothing happens until a human has said yes to that sentence.
17
+ *
18
+ * The family contract this file follows is documented in ./README.md.
19
+ */
20
+ import { z } from 'zod';
21
+
22
+ export const FAMILY = 'forms';
23
+
24
+ export const TOOLS = ['km_get_form_fields', 'km_update_form_fields'];
25
+
26
+ export const PROFILES = ['outreach'];
27
+
28
+ /**
29
+ * Is this a 404 because the ROUTE does not exist, or because the FORM does not?
30
+ *
31
+ * Both families of 404 come back on the same wire, and conflating them would tell
32
+ * an owner "your KM Hub is too old" when the truth is "you gave me the wrong id".
33
+ * The route table's own 404 is the one that carries a `routes` list (index.ts
34
+ * generates it so the list can never drift); a handler's not_found never does.
35
+ */
36
+ function routeMissing(r) {
37
+ if (r.status === 405 || r.status === 501) return true;
38
+ return r.status === 404 && Array.isArray(r.data?.routes);
39
+ }
40
+
41
+ const NOT_SUPPORTED =
42
+ 'This KM Hub is running a version that cannot read or edit form fields from the terminal yet. The workspace is fine ' +
43
+ 'and every other tool works as normal. Form fields are editable in the web app at https://hub.kivimedia.co.';
44
+
45
+ const optionSchema = z.union([
46
+ z.string().min(1).max(200),
47
+ z.object({
48
+ value: z.string().min(1).max(200).describe('What gets stored and, if tag_answer is on, becomes the tag.'),
49
+ label: z.string().min(1).max(200).optional().describe('What the customer reads. Defaults to the value.'),
50
+ }),
51
+ ]);
52
+
53
+ const FIELD_TYPES = ['text', 'textarea', 'select', 'radio', 'checkbox', 'date', 'phone', 'email'];
54
+
55
+ export function register(server, call, { out, text }) {
56
+ server.tool(
57
+ 'km_get_form_fields',
58
+ 'Every question one booking form asks: the built-in ones the page renders itself (phone, zip, surface type, ' +
59
+ 'preferred date and time, how they heard about you, guests of honour) with whether each is required, plus this ' +
60
+ "workspace's own custom questions with their type, options and keys. Also returns a warnings list for anything " +
61
+ 'the form asks TWICE, which is invisible in the data because a built-in question and a custom one live in ' +
62
+ 'different places and only meet on the rendered page. Use it before advising anything about a form, and always ' +
63
+ 'before editing one, because the keys you need for an edit are only here. Get the form id from km_list_forms. ' +
64
+ 'Read only.',
65
+ {
66
+ form_id: z.string().describe('UUID of the form. km_list_forms returns it. A slug will not work here.'),
67
+ },
68
+ async ({ form_id }) => {
69
+ const r = await call('GET', `/forms/${encodeURIComponent(String(form_id || '').trim())}`);
70
+ if (routeMissing(r)) return text(NOT_SUPPORTED, true);
71
+ return out(r);
72
+ },
73
+ );
74
+
75
+ server.tool(
76
+ 'km_update_form_fields',
77
+ 'Change the questions on a booking form: remove a custom question, add one, edit one, or make a built-in question ' +
78
+ 'required or optional. Use it when the owner says the form asks something twice, asks for the wrong thing, is ' +
79
+ 'missing a question, or should stop making something compulsory. ' +
80
+ '🚨 THIS FORM IS A PUBLIC PAGE. Whatever you change here is what the next customer is asked. Making a field ' +
81
+ 'required blocks every submission that leaves it blank, so a careless required flag is an outage nobody sees ' +
82
+ 'until enquiries stop arriving. Run km_get_form_fields first and work from the real keys: this tool refuses a ' +
83
+ 'key that is not on the form rather than quietly doing nothing. ' +
84
+ 'Removing a custom field stops the question being asked from now on and does NOT delete answers already ' +
85
+ 'collected. Only the built-in phone question reaches the client record in the CRM; a custom phone field is ' +
86
+ 'stored on the submission and nowhere else, which is usually the reason a form has two of them.',
87
+ {
88
+ form_id: z.string().describe('UUID of the form, from km_list_forms or km_get_form_fields.'),
89
+ remove: z
90
+ .array(z.string())
91
+ .optional()
92
+ .describe('Keys of custom fields to stop asking, e.g. ["field_3"]. Keys come from km_get_form_fields.'),
93
+ add: z
94
+ .array(
95
+ z.object({
96
+ label: z.string().min(1).max(200).describe('The question the customer reads.'),
97
+ type: z.enum(FIELD_TYPES).describe('select, radio and checkbox need options. phone renders a tel input.'),
98
+ required: z.boolean().optional().describe('True makes it compulsory. Leave it out for optional.'),
99
+ placeholder: z.string().max(200).optional().describe('Grey hint text inside the box.'),
100
+ options: z.array(optionSchema).optional().describe('The choices, for select, radio and checkbox.'),
101
+ tag_answer: z
102
+ .boolean()
103
+ .optional()
104
+ .describe(
105
+ "Turn the customer's answer into a tag on their client record, so that segment can be emailed later. " +
106
+ 'Only allowed on select, radio and checkbox: on free text it would create a new tag for every ' +
107
+ 'spelling a customer uses.',
108
+ ),
109
+ }),
110
+ )
111
+ .optional()
112
+ .describe('New questions. Keys are assigned automatically unless you supply one.'),
113
+ update: z
114
+ .array(
115
+ z.object({
116
+ key: z.string().describe('Key of the existing custom field to change.'),
117
+ label: z.string().min(1).max(200).optional(),
118
+ type: z.enum(FIELD_TYPES).optional(),
119
+ required: z.boolean().optional(),
120
+ placeholder: z.string().max(200).nullable().optional().describe('Send null to clear it.'),
121
+ options: z.array(optionSchema).optional(),
122
+ tag_answer: z.boolean().optional(),
123
+ }),
124
+ )
125
+ .optional()
126
+ .describe('Changes to existing custom questions. Anything you leave out stays as it is.'),
127
+ built_in_required: z
128
+ .object({
129
+ phone: z.boolean().optional(),
130
+ zip: z.boolean().optional(),
131
+ surface: z.boolean().optional(),
132
+ preferredDate: z.boolean().optional(),
133
+ preferredTime: z.boolean().optional(),
134
+ heardAbout: z.boolean().optional(),
135
+ guestOfHonor: z.boolean().optional(),
136
+ })
137
+ .optional()
138
+ .describe(
139
+ 'Make the page\'s own questions compulsory or not, e.g. {"phone": true}. These are the only built-in fields ' +
140
+ 'that exist; any other name is refused rather than saved where nothing would read it.',
141
+ ),
142
+ confirm_token: z
143
+ .string()
144
+ .optional()
145
+ .describe(
146
+ 'Leave this out on the first call. This action changes a public page, so it needs an explicit yes from the '
147
+ + 'person you are working with: KM Hub answers 409 with a plain description of exactly which questions '
148
+ + 'would change, plus a token. Show them that description in those words, wait for a real answer, and only '
149
+ + 'then call again with the token and the SAME arguments. A yes for one change never authorises a different one.',
150
+ ),
151
+ },
152
+ async ({ form_id, ...changes }) => {
153
+ const r = await call('PATCH', `/forms/${encodeURIComponent(String(form_id || '').trim())}/fields`, changes);
154
+ if (routeMissing(r)) return text(NOT_SUPPORTED, true);
155
+ return out(r);
156
+ },
157
+ );
158
+ }
package/tools/gols.mjs ADDED
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Tool family: gols - GOLS, the Grand Opening / Event Scout, read and write.
3
+ *
4
+ * km_gols_finds -> GET /gols/finds (free)
5
+ * km_gols_scan -> POST /gols/scan (AI-metered: price first, then a confirm)
6
+ * km_gols_push_find -> POST /gols/finds/:id/push (confirm: creates a deal + queues a draft)
7
+ * km_gols_dismiss -> POST /gols/finds/:id/dismiss (free)
8
+ *
9
+ * Owners call it "GOLS", "gols", "grand openings", "new businesses opening near
10
+ * me". Every description below says all of those, because on 22-Sep-26 a session
11
+ * asked about "gols" found nothing matching and asked the owner to choose between
12
+ * three guesses.
13
+ *
14
+ * Nothing here contacts anybody. A pushed find's first message is a DRAFT in the
15
+ * Approval Queue. The family contract this file follows is documented in ./README.md.
16
+ */
17
+ import { z } from 'zod';
18
+
19
+ export const FAMILY = 'gols';
20
+
21
+ export const TOOLS = ['km_gols_finds', 'km_gols_scan', 'km_gols_push_find', 'km_gols_dismiss'];
22
+
23
+ export const PROFILES = ['outreach'];
24
+
25
+ function routeMissing(r) {
26
+ return r.status === 404 || r.status === 405 || r.status === 501;
27
+ }
28
+
29
+ const NOT_SUPPORTED =
30
+ 'This KM Hub does not expose GOLS (the grand-opening scout) to the terminal yet. Nothing is broken; it is in the ' +
31
+ 'web app at https://hub.kivimedia.co/gols. Nothing was charged.';
32
+
33
+ const CONFIRM_TOKEN = z
34
+ .string()
35
+ .optional()
36
+ .describe(
37
+ 'Leave this out on the first call. KM Hub answers 409 with a plain description of exactly what would happen, ' +
38
+ 'plus a token. Show the person that description in those words, wait for a real yes, then call again with the ' +
39
+ 'token and the SAME arguments.',
40
+ );
41
+
42
+ export function register(server, call, { out, text, qs }) {
43
+ server.tool(
44
+ 'km_gols_finds',
45
+ 'GOLS (the Grand Opening / Event Scout, also written "gols"): the businesses KM Hub has found that just opened or ' +
46
+ 'have announced an opening near the owner, each scored hot / warm / cold for how likely it is to book event ' +
47
+ 'services for a grand opening, launch or ribbon cutting. Best first. Reach for it when the owner asks about ' +
48
+ 'GOLS, grand openings, new businesses near them, or "who is opening soon". `when` says "Opens <date>" only ' +
49
+ 'for a date the announcement stated; "In the news <date>" is just the article date, so never call that an ' +
50
+ 'opening date. Free, read only. To find more, use km_gols_scan; to act on one, km_gols_push_find.',
51
+ {
52
+ status: z
53
+ .enum(['new', 'pushed', 'dismissed', 'all'])
54
+ .optional()
55
+ .describe('Which finds. Default "new" = not yet acted on. "pushed" = already in the pipeline.'),
56
+ tier: z.enum(['hot', 'warm', 'cold']).optional().describe('Only this fit tier. Leave out for all.'),
57
+ city: z.string().optional().describe('Only finds from scans of this city, as the scan was run.'),
58
+ limit: z.number().int().min(1).max(100).optional().describe('How many. Default 30.'),
59
+ },
60
+ async ({ status, tier, city, limit }) => {
61
+ const r = await call('GET', `/gols/finds${qs({ status, tier, city, limit })}`);
62
+ if (routeMissing(r)) return text(NOT_SUPPORTED, true);
63
+ return out(r);
64
+ },
65
+ );
66
+
67
+ server.tool(
68
+ 'km_gols_scan',
69
+ [
70
+ 'Run a GOLS scan (grand-opening scout): search the news (and Google Places when connected) for businesses that',
71
+ 'just opened near a city, or with ahead=true for ones that have ANNOUNCED an opening that has not happened yet,',
72
+ 'then score each one for event-services fit and save it. It takes about a minute and returns the finds.',
73
+ 'THIS COSTS MONEY (AI time, billed to the workspace). Call it FIRST without confirm_spend: nothing runs and you',
74
+ 'get the price. Tell the person the price, and on a yes call again with confirm_spend true; KM Hub then answers',
75
+ '409 with the exact action and a confirm_token, which you show them and send back with the same arguments.',
76
+ 'Never start one on your own initiative. It contacts nobody.',
77
+ ].join(' '),
78
+ {
79
+ city: z.string().describe('The city to scan around, as the person said it. For example "Southfield".'),
80
+ state: z.string().describe('State or province, for example "MI" or "Ontario".'),
81
+ radius_miles: z.number().int().min(1).max(200).optional().describe('How far around the city. Default 25.'),
82
+ lookback_days: z.number().int().min(1).max(120).optional().describe('How far back in the news to look. Default 30.'),
83
+ ahead: z
84
+ .boolean()
85
+ .optional()
86
+ .describe('true = announced openings that have NOT happened yet (the best time to pitch). Default false = already opened.'),
87
+ vertical: z.string().optional().describe('The trade to score fit for, only if it differs from the workspace, e.g. "balloon decor".'),
88
+ zip: z.string().optional().describe('ZIP code, only if the person gave one.'),
89
+ confirm_spend: z
90
+ .boolean()
91
+ .optional()
92
+ .describe('Leave out on the first call (price only, nothing runs). true ONLY after the person agreed to the price.'),
93
+ confirm_token: CONFIRM_TOKEN,
94
+ },
95
+ async (args) => {
96
+ const r = await call('POST', '/gols/scan', args);
97
+ if (routeMissing(r)) return text(NOT_SUPPORTED, true);
98
+ return out(r);
99
+ },
100
+ );
101
+
102
+ server.tool(
103
+ 'km_gols_push_find',
104
+ 'Take one GOLS grand-opening find into the pipeline: it becomes a deal at Inbound, and KM Hub finds a contact ' +
105
+ 'email for the business and writes a grand-opening approach as a DRAFT in the Approval Queue (a small AI and ' +
106
+ 'lookup cost). Nobody is contacted until a person approves the draft. Needs an explicit yes: the first call ' +
107
+ 'returns 409 with what would happen and a confirm_token. Pushing a find that is already pushed changes nothing.',
108
+ {
109
+ find_id: z.string().describe('The find id from km_gols_finds or km_gols_scan.'),
110
+ confirm_token: CONFIRM_TOKEN,
111
+ },
112
+ async ({ find_id, confirm_token }) => {
113
+ const id = String(find_id || '').trim();
114
+ if (!id) return text('I need the find id. km_gols_finds lists them.');
115
+ const r = await call('POST', `/gols/finds/${encodeURIComponent(id)}/push`, { confirm_token });
116
+ if (routeMissing(r)) return text(NOT_SUPPORTED, true);
117
+ return out(r);
118
+ },
119
+ );
120
+
121
+ server.tool(
122
+ 'km_gols_dismiss',
123
+ 'Hide a GOLS grand-opening find the owner does not want, so it stops showing in the working list. Nothing is ' +
124
+ 'deleted and nobody is contacted. Only works on finds still marked new.',
125
+ { find_id: z.string().describe('The find id from km_gols_finds.') },
126
+ async ({ find_id }) => {
127
+ const id = String(find_id || '').trim();
128
+ if (!id) return text('I need the find id. km_gols_finds lists them.');
129
+ const r = await call('POST', `/gols/finds/${encodeURIComponent(id)}/dismiss`, {});
130
+ if (routeMissing(r)) return text(NOT_SUPPORTED, true);
131
+ return out(r);
132
+ },
133
+ );
134
+ }