@kivimedia/kmhub 2.9.1 → 2.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +170 -170
  2. package/bin/kmhub.mjs +896 -896
  3. package/coach-book-output-guard.mjs +760 -760
  4. package/index.mjs +57 -57
  5. package/package.json +56 -56
  6. package/prompts/briefing.md +29 -29
  7. package/prompts/luxury.md +70 -70
  8. package/prompts/play.md +49 -49
  9. package/prompts/run.md +37 -36
  10. package/prompts/setup.md +33 -33
  11. package/prompts/vs-booked.md +46 -46
  12. package/prompts/what-can-you-do.md +40 -40
  13. package/prompts.mjs +110 -110
  14. package/read-only-tools.json +143 -142
  15. package/remote.mjs +929 -929
  16. package/tools/balloon-costing.mjs +80 -80
  17. package/tools/booking-equipment.mjs +110 -110
  18. package/tools/bridges.mjs +54 -54
  19. package/tools/briefing.mjs +91 -91
  20. package/tools/calendar.mjs +170 -170
  21. package/tools/capabilities.mjs +155 -155
  22. package/tools/catalog.mjs +288 -288
  23. package/tools/clubs.mjs +176 -176
  24. package/tools/coach.mjs +771 -771
  25. package/tools/compare.mjs +76 -76
  26. package/tools/core.mjs +244 -244
  27. package/tools/crm.mjs +209 -209
  28. package/tools/dubsado.mjs +137 -137
  29. package/tools/exports.mjs +128 -128
  30. package/tools/fact-review.mjs +125 -125
  31. package/tools/flows.mjs +261 -261
  32. package/tools/forms.mjs +158 -158
  33. package/tools/gols.mjs +144 -134
  34. package/tools/hr.mjs +162 -162
  35. package/tools/knowledge.mjs +129 -125
  36. package/tools/marketing.mjs +396 -396
  37. package/tools/meta.mjs +245 -245
  38. package/tools/military.mjs +244 -244
  39. package/tools/money.mjs +235 -197
  40. package/tools/outreach.mjs +238 -238
  41. package/tools/pending.mjs +122 -122
  42. package/tools/photos.mjs +140 -140
  43. package/tools/plays.mjs +244 -244
  44. package/tools/profile.mjs +118 -118
  45. package/tools/radar.mjs +173 -173
  46. package/tools/recurring-invoices.mjs +149 -149
  47. package/tools/reengage.mjs +434 -434
  48. package/tools/schedules.mjs +55 -55
  49. package/tools/setup.mjs +168 -168
  50. package/tools/sops-bridges.mjs +86 -86
  51. package/tools/sops.mjs +314 -314
  52. package/tools/sourcing.mjs +268 -268
  53. package/tools/strategy.mjs +146 -146
  54. package/tools/studio.mjs +132 -132
  55. package/tools/venueradar.mjs +151 -151
  56. package/tools/voice.mjs +137 -134
  57. package/tools.mjs +407 -407
package/tools/flows.mjs CHANGED
@@ -1,261 +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
- }
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
+ }