@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.
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 +109 -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,118 @@
1
+ /**
2
+ * Tool family: profile - who this business IS, and correcting it when it is wrong.
3
+ *
4
+ * km_get_profile -> GET /profile
5
+ * km_update_profile -> PATCH /profile
6
+ *
7
+ * WHY THIS EXISTS. A connector could already READ enough to notice that a profile
8
+ * was wrong and had no way to fix it. The nearest write was km_propose_business_fact,
9
+ * which lands a PENDING proposal: it does not change the talent category, the
10
+ * timezone or the sending identity, and nothing downstream reads it until a human
11
+ * approves it in the web app. A model that answers "I have recorded that you are a
12
+ * mentalist" on the back of a proposal has told the user something untrue.
13
+ *
14
+ * 🚨 The talent category (verticals) is the field that matters. It decides the
15
+ * flavour every AI writer and every lead scout works in. A magician's workspace
16
+ * that is really a mentalist's produces subtly wrong copy in every direction, and
17
+ * the owner usually cannot tell WHY it sounds off.
18
+ *
19
+ * 🚨 km_update_profile is a confirmed write. KM Hub answers 409 with the exact
20
+ * before -> after sentence it wants approved, written server-side from the live
21
+ * values. Show that sentence and wait for a real answer.
22
+ *
23
+ * The family contract this file follows is documented in ./README.md.
24
+ */
25
+ import { z } from 'zod';
26
+
27
+ export const FAMILY = 'profile';
28
+
29
+ export const TOOLS = ['km_get_profile', 'km_update_profile'];
30
+
31
+ export const PROFILES = ['*'];
32
+
33
+ function routeMissing(r) {
34
+ return r.status === 404 || r.status === 405 || r.status === 501;
35
+ }
36
+
37
+ const NOT_SUPPORTED =
38
+ 'Your KM Hub does not expose the business profile to the terminal yet. That is not a fault: the workspace is fine ' +
39
+ 'and every other tool works as normal. The profile is in the web app at https://hub.kivimedia.co under Settings, ' +
40
+ 'and this connector will read and edit it once your KM Hub is on a build that publishes it.';
41
+
42
+ export function register(server, call, { out, text }) {
43
+ server.tool(
44
+ 'km_get_profile',
45
+ 'Who this business actually is, as the software understands it: its name, its talent category, the timezone and ' +
46
+ 'currency it works in, the language the AI writes in, and the public email, phone, website and address clients ' +
47
+ 'see on quotes and invoices. This is the live row the web app Settings page reads, not a cached copy. ' +
48
+ 'Call it before writing anything in the business\'s own voice, before quoting a price, and before saying anything ' +
49
+ 'about what they do. ' +
50
+ '🚨 The talent category is the one to look at hardest. It decides the flavour every AI writer and every lead scout ' +
51
+ 'works in, so a workspace still set to Magician when the person is a Mentalist produces subtly wrong copy ' +
52
+ 'everywhere, and the owner usually cannot tell why it sounds off. If what you see does not match what the person ' +
53
+ 'has told you, say so plainly and offer to correct it with km_update_profile. Do not quietly work around it, and ' +
54
+ 'do not file it as a business-knowledge fact instead: a proposed fact does not change the profile. Read only.',
55
+ {},
56
+ async () => {
57
+ const r = await call('GET', '/profile');
58
+ if (routeMissing(r)) return text(NOT_SUPPORTED, true);
59
+ return out(r);
60
+ },
61
+ );
62
+
63
+ server.tool(
64
+ 'km_update_profile',
65
+ 'Correct the live business profile: the talent category, the business name, the timezone, the currency, the ' +
66
+ 'language the AI writes in, or the public email, phone, website and address. Send only the fields that are wrong. ' +
67
+ 'This is a real change to the live workspace and it takes effect immediately - the web app Settings page will show ' +
68
+ 'the same values a second later. ' +
69
+ 'Call km_get_profile first so you are changing from a value you have actually seen, and so you use vertical keys ' +
70
+ 'from the vocabulary it returns rather than inventing one. ' +
71
+ '🚨 This needs an explicit yes from the person, in this turn. KM Hub answers 409 with a plain sentence naming ' +
72
+ 'exactly what would change, from what, to what, written by the server from the live values. Put that sentence to ' +
73
+ 'them in those words, wait for a real answer, and only then call again with the confirm_token and the SAME ' +
74
+ 'arguments. Do not treat something said earlier in the conversation as agreement. ' +
75
+ '🚨 Changing the currency does NOT convert any money already recorded: every stored amount keeps its number and is ' +
76
+ 'simply re-labelled. Changing the talent category does not rewrite anything already written, only work that comes ' +
77
+ 'after it. ' +
78
+ 'The account type (solo or agency), the logo, the default tax rate and the portal colour are NOT editable from ' +
79
+ 'here and no argument unlocks them - they are money, an upload, or something you cannot see the result of from a ' +
80
+ 'terminal. Those stay in KM Hub.',
81
+ {
82
+ name: z.string().optional().describe('The business name, as it should appear to clients.'),
83
+ verticals: z
84
+ .array(z.string())
85
+ .optional()
86
+ .describe(
87
+ 'What this business does, as a list of KM Hub vertical keys, most important first. The valid keys come back '
88
+ + 'from km_get_profile under editable.verticals_vocabulary - use those exact strings and never invent one. '
89
+ + 'It cannot be an empty list.',
90
+ ),
91
+ timezone: z.string().optional().describe('An IANA zone name, for example Asia/Jerusalem or America/New_York.'),
92
+ currency: z.string().optional().describe('A three-letter code KM Hub supports: USD, CAD, GBP, EUR, AUD or ILS.'),
93
+ language: z.string().optional().describe('The language the AI writes to customers in by default: en, he or es.'),
94
+ public_email: z.string().nullable().optional().describe('The address clients see. Send null to clear it.'),
95
+ public_phone: z.string().nullable().optional().describe('The number clients see. Send null to clear it.'),
96
+ website_url: z.string().nullable().optional().describe('Must start with http:// or https://. Send null to clear it.'),
97
+ address: z.string().nullable().optional().describe('The business address on one line. Send null to clear it.'),
98
+ confirm_token: z
99
+ .string()
100
+ .optional()
101
+ .describe(
102
+ 'Leave this out on the first call. KM Hub will answer 409 with the sentence to put to the person and a token. '
103
+ + 'Only after they actually say yes, call again with the token and the SAME arguments. A yes for one change '
104
+ + 'never authorises a different one.',
105
+ ),
106
+ },
107
+ async (args) => {
108
+ // Only forward what the caller actually set: an undefined key would be sent
109
+ // as an absent field anyway, but a null one MEANS "clear this", so the two
110
+ // must not be flattened together.
111
+ const body = {};
112
+ for (const [k, v] of Object.entries(args)) if (v !== undefined) body[k] = v;
113
+ const r = await call('PATCH', '/profile', body);
114
+ if (routeMissing(r)) return text(NOT_SUPPORTED, true);
115
+ return out(r);
116
+ },
117
+ );
118
+ }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Tool family: radar - the scanners that go looking for work, plus the tail.
3
+ *
4
+ * The last of the parity sweep. KM Hub runs a family of radars: new business
5
+ * openings, milestones, schools, libraries, property, races, conferences, events
6
+ * and the gig marketplaces. They looked like nine separate gaps and they are not:
7
+ * every one is a scout mission with a different kind, so two tools reach all of
8
+ * them, and a radar added next year is readable the day it ships.
9
+ *
10
+ * km_list_radar_scans -> GET /radar/missions
11
+ * km_property_scan -> GET /radar/communities
12
+ * km_list_marketplace_leads -> GET /radar/finds
13
+ * km_list_reviews -> GET /ops/reviews
14
+ * km_list_trips -> GET /ops/trips
15
+ * km_list_catalogs -> GET /ops/catalogs
16
+ *
17
+ * 🚨 READ ONLY. Starting a scan spends the client's money on lead-sourcing
18
+ * providers. That already exists as km_start_lead_scout, which was built with cost
19
+ * disclosure around it, and this family deliberately adds no second quieter way
20
+ * to spend.
21
+ *
22
+ * The family contract this file follows is documented in ./README.md.
23
+ */
24
+ import { z } from 'zod';
25
+
26
+ export const FAMILY = 'radar';
27
+
28
+ export const TOOLS = [
29
+ 'km_list_radar_scans',
30
+ 'km_property_scan',
31
+ 'km_list_marketplace_leads',
32
+ 'km_list_reviews',
33
+ 'km_list_trips',
34
+ 'km_list_catalogs',
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 ' +
45
+ 'every 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_radar_scans',
50
+ 'Every automated scan KM Hub has run looking for work, whatever kind: new business openings, milestones, schools, ' +
51
+ 'libraries, property, races, conferences, events and marketplace sweeps. Each returns its kind, phase, progress, ' +
52
+ 'a summary of what it found and any error. ' +
53
+ 'Use it when the user asks whether the lead finding is working, why no new leads have appeared, or what the ' +
54
+ 'radars are doing. ' +
55
+ '🚨 `failed` is the number to lead with. A radar that quietly stopped is a lead SOURCE that stopped, and nothing ' +
56
+ 'else in the workspace will ever mention it: the pipeline just gets thinner for reasons nobody connects to a ' +
57
+ 'broken scan weeks earlier. ' +
58
+ 'Full result blobs are summarised rather than returned whole. Starting a scan costs real money and is done with ' +
59
+ 'km_start_lead_scout or in KM Hub, never here. Read only.',
60
+ {
61
+ kind: z.string().optional().describe('Narrow to one radar type, for example the job_type a previous call returned.'),
62
+ status: z.string().optional().describe('Narrow to one state, for example running, failed or completed.'),
63
+ limit: z.number().int().min(1).max(150).optional().describe('How many scans, newest first. Default 30.'),
64
+ },
65
+ async ({ kind, status, limit }) => {
66
+ const r = await call('GET', `/radar/missions${qs({ kind, status, limit })}`);
67
+ if (routeMissing(r)) return text(NOT_SUPPORTED, true);
68
+ return out(r);
69
+ },
70
+ );
71
+
72
+ server.tool(
73
+ 'km_property_scan',
74
+ 'The residential communities and owner associations Property Radar has on record for one market: apartment ' +
75
+ 'communities, homeowner and property owner associations (HOA and POA), condo associations and 55+ active adult ' +
76
+ 'communities. Each returns its size, tier, management company, any resident events already evidenced, and how ' +
77
+ 'many reachable STAFF contacts it has. ' +
78
+ 'Two ways in, because the two buyers are filed differently: give a `city` for apartment communities, or a ' +
79
+ '`county` for associations, which is how the state registries publish them. ' +
80
+ '🚨 `staff_contacts` is the number that decides whether a row is actionable, not the contact field. An ' +
81
+ 'association is run by paid staff (a lifestyle director, an activities director, a community manager) and by ' +
82
+ 'unpaid volunteers on its board. Only the staff are a lawful outreach target: a board member\'s address is a ' +
83
+ 'personal one, it changes at every annual meeting, and several states forbid the association from releasing it ' +
84
+ 'even to a fellow owner. A row with zero staff contacts needs enrichment before anyone writes to it. ' +
85
+ '🚨 Timing is the other thing to say out loud. Around nine in ten associations run a calendar fiscal year: the ' +
86
+ 'events budget is drafted from late July, bids close through October and the board votes in November. A pitch ' +
87
+ 'in August is inside the number being written; the same pitch in February is arguing with one already fixed for ' +
88
+ 'twelve months. ' +
89
+ 'Starting a sweep costs real money and is done with km_start_lead_scout or in KM Hub, never here. Read only.',
90
+ {
91
+ state: z.string().describe('Two-letter US state code, like TX or VA.'),
92
+ city: z.string().optional().describe('For apartment communities. Case insensitive.'),
93
+ county: z.string().optional().describe('For homeowner and property owner associations, which are filed by county. Name only, without the word County.'),
94
+ assoc_kind: z.string().optional().describe('Narrow to one kind: hoa, poa, condo_assoc, master_assoc, sub_assoc, or any_association for all of them.'),
95
+ limit: z.number().int().min(1).max(200).optional().describe('How many communities. Default 40.'),
96
+ },
97
+ async ({ state, city, county, assoc_kind, limit }) => {
98
+ const r = await call('GET', `/radar/communities${qs({ state, city, county, assoc_kind, limit })}`);
99
+ if (routeMissing(r)) return text(NOT_SUPPORTED, true);
100
+ return out(r);
101
+ },
102
+ );
103
+
104
+ server.tool(
105
+ 'km_list_marketplace_leads',
106
+ 'Leads swept from the gig marketplaces this business sells on, each with its portal, the event, a fit score, a ' +
107
+ 'margin verdict and whether anybody turned it into a deal or skipped it. ' +
108
+ '🚨 `unactioned` is the important number: leads that are neither a deal nor skipped. On gig portals speed decides ' +
109
+ 'who gets the work, so an unactioned lead is almost always a LOST one rather than a waiting one, and saying that ' +
110
+ 'plainly is more useful than listing them neutrally. ' +
111
+ '`skip_reason` is worth reading in bulk: the same reason recurring usually means the sweep is aimed slightly wrong. ' +
112
+ 'Replying happens in KM Hub. Read only.',
113
+ {
114
+ limit: z.number().int().min(1).max(200).optional().describe('How many leads, newest first. Default 40.'),
115
+ },
116
+ async ({ limit }) => {
117
+ const r = await call('GET', `/radar/finds${qs({ limit })}`);
118
+ if (routeMissing(r)) return text(NOT_SUPPORTED, true);
119
+ return out(r);
120
+ },
121
+ );
122
+
123
+ server.tool(
124
+ 'km_list_reviews',
125
+ 'Reviews this business has received and requested, with platform, rating, the text, and whether each was replied to. ' +
126
+ 'Use it when the user asks about their reputation, what clients are saying, or before any play that leans on ' +
127
+ 'social proof. ' +
128
+ '🚨 Lead with `unreplied`. An unanswered review, good or bad, is read by every future buyer who finds it, and a ' +
129
+ 'reply is the cheapest reputation work available. `requested_but_not_received` is the other useful number: those ' +
130
+ 'are asks that went nowhere. Requesting and replying happen in KM Hub. Read only.',
131
+ {
132
+ limit: z.number().int().min(1).max(200).optional().describe('How many reviews, newest first. Default 40.'),
133
+ },
134
+ async ({ limit }) => {
135
+ const r = await call('GET', `/ops/reviews${qs({ limit })}`);
136
+ if (routeMissing(r)) return text(NOT_SUPPORTED, true);
137
+ return out(r);
138
+ },
139
+ );
140
+
141
+ server.tool(
142
+ 'km_list_trips',
143
+ 'Mileage logged in a window, with distance and what is reimbursable. Defaults to the last 90 days. ' +
144
+ 'Use it when the user asks about travel, vehicle costs or expenses tied to getting to jobs. ' +
145
+ 'Reimbursement is in CENTS. Unclaimed mileage is money the business is entitled to and routinely does not take, ' +
146
+ 'so a large reimbursable total that never appears in the accounts is worth mentioning. Read only.',
147
+ {
148
+ from: z.string().optional().describe('Start date, YYYY-MM-DD. Default 90 days ago.'),
149
+ to: z.string().optional().describe('End date, YYYY-MM-DD. Default tomorrow.'),
150
+ limit: z.number().int().min(1).max(500).optional().describe('How many trips. Default 100. Check `truncated` before trusting a total.'),
151
+ },
152
+ async ({ from, to, limit }) => {
153
+ const r = await call('GET', `/ops/trips${qs({ from, to, limit })}`);
154
+ if (routeMissing(r)) return text(NOT_SUPPORTED, true);
155
+ return out(r);
156
+ },
157
+ );
158
+
159
+ server.tool(
160
+ 'km_list_catalogs',
161
+ 'The booking catalogues that decide what a buyer is offered on the public booking pages, which is default, and ' +
162
+ 'which customer types each is aimed at. ' +
163
+ 'Use it before describing what this business sells publicly, because the catalogue and the internal price list ' +
164
+ 'are not the same thing and a buyer only ever sees the catalogue. Prices come from km_get_offers_and_pricing, ' +
165
+ 'never from here. Editing happens in KM Hub. Read only.',
166
+ {},
167
+ async () => {
168
+ const r = await call('GET', '/ops/catalogs');
169
+ if (routeMissing(r)) return text(NOT_SUPPORTED, true);
170
+ return out(r);
171
+ },
172
+ );
173
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Tool family: recurring-invoices - subscription billing from the terminal.
3
+ *
4
+ * WHY THIS FAMILY EXISTS. On 22-Sep-2026 Jackie (Dazzling Balloons) set out to
5
+ * launch a monthly decor subscription - billed monthly in advance on the 1st,
6
+ * three tiers, a three-month minimum. Her terminal could read every invoice she
7
+ * had ever raised and could not set up a single repeating one: the
8
+ * recurring_invoices table was writable only from the "Make recurring" panel on
9
+ * an existing invoice in the web app. A recurring plan is one bill with a
10
+ * schedule inside it, and the schedule was mouse-only.
11
+ *
12
+ * 🚨 auto_send DEFAULTS FALSE, on purpose. Off, each month's invoice is created
13
+ * as a DRAFT and nobody is emailed - the owner looks at it and sends it. On, the
14
+ * cron marks it sent and emails the client the pay link unattended, every month,
15
+ * forever. That second one is a standing instruction to charge somebody, so a
16
+ * terminal never picks it by omission: the person has to ask, and the
17
+ * confirmation sentence says in plain words which one they are getting.
18
+ *
19
+ * Every write here moves money, so every write is confirm-gated.
20
+ *
21
+ * The family contract this file follows is documented in ./README.md.
22
+ */
23
+ import { z } from 'zod';
24
+
25
+ export const FAMILY = 'recurring-invoices';
26
+
27
+ export const TOOLS = [
28
+ 'km_list_recurring_invoices', 'km_create_recurring_invoice', 'km_update_recurring_invoice',
29
+ ];
30
+
31
+ // Lands in `full` only. An EMPTY list means exactly that - ['*'] would opt into
32
+ // every profile, which is the mistake tools.mjs documents six families making.
33
+ export const PROFILES = [];
34
+
35
+ /** Route-level 404 (carries a routes list) vs a handler's not_found. See forms.mjs. */
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 set up repeating invoices from the terminal yet. The workspace is ' +
43
+ 'fine and every other tool works as normal. Recurring invoices are set up in the web app at ' +
44
+ 'https://hub.kivimedia.co: open an invoice, then "Make recurring".';
45
+
46
+ const CONFIRM_DESC =
47
+ 'Leave this out on the first call. This action commits to billing somebody money on a schedule, so it needs an '
48
+ + 'explicit yes from the person you are working with: KM Hub answers 409 with a plain sentence saying who gets '
49
+ + 'billed how much and how often, plus a token. Show them that sentence in those words, wait for a real answer, '
50
+ + 'and only then call again with the token and the SAME arguments. A yes for one plan never authorises a different '
51
+ + 'one.';
52
+
53
+ const LINE_ITEM = z.object({
54
+ description: z.string().min(1).max(500).describe('What the line says on the invoice, e.g. "Dazzling Decor Club - Gold tier, monthly".'),
55
+ unit_price_cents: z.number().int().min(0).describe('Price per unit in CENTS. 45000 is $450.00.'),
56
+ quantity: z.number().gt(0).max(9999).optional().describe('How many. Leave it out for 1; decimals like 1.5 are fine.'),
57
+ });
58
+
59
+ export function register(server, call, { out, text }) {
60
+ server.tool(
61
+ 'km_list_recurring_invoices',
62
+ 'The repeating invoices this workspace bills: what each one charges, to whom, when it next runs, whether it ' +
63
+ 'is paused, and whether it emails itself or waits as a draft for approval. Each row carries the computed ' +
64
+ 'monthly total in real money, plus a plain_english line, and the response totals up the active monthly ' +
65
+ 'recurring revenue. ' +
66
+ 'THE MODEL, so you never have to guess: a recurring invoice is a TEMPLATE, not an invoice. A daily cron ' +
67
+ 'creates the next real invoice when next_run_date arrives and moves the date on a month. So nothing here ' +
68
+ 'appears in km_list_invoices until it has actually run at least once, and pausing one (active false) never ' +
69
+ 'touches invoices it has already raised - those are still owed. Read only.',
70
+ {
71
+ active: z.boolean().optional().describe('True for only the live plans, false for only the paused ones. Leave it out for all of them.'),
72
+ },
73
+ async ({ active }) => {
74
+ const q = active === undefined ? '' : `?active=${active ? 'true' : 'false'}`;
75
+ const r = await call('GET', `/recurring-invoices${q}`);
76
+ if (routeMissing(r)) return text(NOT_SUPPORTED, true);
77
+ return out(r);
78
+ },
79
+ );
80
+
81
+ server.tool(
82
+ 'km_create_recurring_invoice',
83
+ 'Set up a repeating invoice: a subscription, a retainer, a payment plan - anything billed on the same day ' +
84
+ 'every month. This is how a monthly membership actually gets charged; the booking catalog only sells it. ' +
85
+ 'Prices are CENTS: unit_price_cents 45000 is $450. cadence is monthly and only monthly (the table refuses ' +
86
+ 'anything else, so weekly and annual plans cannot be stored yet). next_run_date is the day the FIRST ' +
87
+ 'invoice is raised AND the day of the month every later one repeats on, so 2026-10-01 means the 1st. ' +
88
+ 'due_in_days 0 means due the day it is raised, which is what "billed in advance" looks like. ' +
89
+ '🚨 auto_send defaults to FALSE and you should usually leave it alone: each month the invoice appears as a ' +
90
+ 'DRAFT and the owner approves and sends it. Pass auto_send true ONLY if the owner has explicitly said they ' +
91
+ 'want the client emailed and charged automatically with nobody looking first - and say out loud that it ' +
92
+ 'repeats unattended every month until paused. It also needs a client with an email address on file. ' +
93
+ 'This is a confirmed write; expect a 409 spelling out who is billed how much and how often.',
94
+ {
95
+ title: z.string().min(1).max(200).describe('Name the plan, e.g. "Dazzling Decor Club - Gold". The owner reads this in the list, and the generated invoices quote it in their notes.'),
96
+ line_items: z.array(LINE_ITEM).min(1).max(50).describe('What is billed each month. At least one line: a template with none generates nothing at all.'),
97
+ client_id: z.string().optional().describe('UUID from km_list_clients: who gets billed. Leave it out only for a plan not yet tied to a client (and then auto_send cannot be used).'),
98
+ cadence: z.enum(['monthly']).optional().describe("Leave it out. 'monthly' is the only value the table accepts today."),
99
+ next_run_date: z.string().describe('yyyy-mm-dd, e.g. 2026-10-01. The first billing date, and the day of the month it repeats on. The 31st clamps to the 28th in February.'),
100
+ due_in_days: z.number().int().min(0).max(365).optional().describe('Days from issue to due date. Leave it out for 14; use 0 for billed-in-advance, due immediately.'),
101
+ discount_cents: z.number().int().min(0).optional().describe('A flat discount off each month, in cents. Cannot exceed what the line items add up to.'),
102
+ tax_rate: z.number().min(0).max(100).optional().describe('Tax as a PERCENTAGE: 7.5 means 7.5%, not 0.075.'),
103
+ currency: z.string().length(3).optional().describe('3-letter code like USD. Leave it out to use the workspace currency.'),
104
+ auto_send: z.boolean().optional().describe('Leave it out for false: the invoice is a draft each month and the owner sends it. True emails the client the pay link automatically, unattended, every month, with nobody checking it first.'),
105
+ booking_id: z.string().optional().describe('UUID of a booking every generated invoice should hang off, if there is one.'),
106
+ source_invoice_id: z.string().optional().describe('UUID of the invoice this plan was modelled on, for provenance. Optional.'),
107
+ confirm_token: z.string().optional().describe(CONFIRM_DESC),
108
+ },
109
+ async (body) => {
110
+ const r = await call('POST', '/recurring-invoices', body);
111
+ if (routeMissing(r)) return text(NOT_SUPPORTED, true);
112
+ return out(r);
113
+ },
114
+ );
115
+
116
+ server.tool(
117
+ 'km_update_recurring_invoice',
118
+ 'Change a repeating invoice: PAUSE it (active false - no invoice is generated at all until resumed), resume ' +
119
+ 'it, change what it bills each month (line_items, discount_cents, tax_rate), move the next billing date, ' +
120
+ 'change the payment terms, or switch auto-send on or off. ' +
121
+ 'Run km_list_recurring_invoices first and work from real ids. Send only the fields you are changing - but ' +
122
+ 'line_items REPLACES the whole list, so send every line, not just the changed one. ' +
123
+ 'Everything here affects FUTURE invoices only: invoices already raised keep their amounts and are still ' +
124
+ 'owed, so ending a subscription means pausing the plan, not undoing the bills. ' +
125
+ '🚨 Moving next_run_date EARLIER can bill a month again - the generator bills on whatever date it finds, ' +
126
+ 'and it has no memory of which months it already covered. The confirmation sentence and the response both ' +
127
+ 'say so when that is what you are doing. There is no way to change who a plan bills: create a new plan and ' +
128
+ 'pause this one, so a template that has billed somebody cannot quietly start billing somebody else. ' +
129
+ 'This is a confirmed write; expect a 409 naming the plan and the change first.',
130
+ {
131
+ recurring_invoice_id: z.string().describe('UUID of the plan, from km_list_recurring_invoices.'),
132
+ active: z.boolean().optional().describe('false pauses it: nothing is generated until it is true again. true resumes it.'),
133
+ title: z.string().min(1).max(200).optional(),
134
+ line_items: z.array(LINE_ITEM).min(1).max(50).optional().describe('The COMPLETE list of what is billed each month. It replaces what is there.'),
135
+ discount_cents: z.number().int().min(0).optional().describe('Flat discount off each month, in cents.'),
136
+ tax_rate: z.number().min(0).max(100).optional().describe('Tax as a PERCENTAGE: 7.5 means 7.5%.'),
137
+ currency: z.string().length(3).nullable().optional().describe('3-letter code, or null to fall back to the workspace currency.'),
138
+ next_run_date: z.string().optional().describe('yyyy-mm-dd. The next billing date, and from then on the day of the month it repeats on. Moving it LATER skips ahead; moving it EARLIER can re-bill a month.'),
139
+ due_in_days: z.number().int().min(0).max(365).optional().describe('Days from issue to due date on each generated invoice.'),
140
+ auto_send: z.boolean().optional().describe('True starts emailing the client the pay link automatically each month with nobody checking first; false goes back to a draft the owner approves. Only ask for true if the owner explicitly wants it.'),
141
+ confirm_token: z.string().optional().describe(CONFIRM_DESC),
142
+ },
143
+ async ({ recurring_invoice_id, ...changes }) => {
144
+ const r = await call('PATCH', `/recurring-invoices/${encodeURIComponent(String(recurring_invoice_id || '').trim())}`, changes);
145
+ if (routeMissing(r)) return text(NOT_SUPPORTED, true);
146
+ return out(r);
147
+ },
148
+ );
149
+ }