@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/crm.mjs CHANGED
@@ -1,209 +1,209 @@
1
- /**
2
- * Tool family: crm - opening a record, changing it, and keeping the follow-ups.
3
- *
4
- * core gives you the lists: clients, deals, bookings. This family gives you the
5
- * one record behind a list row, the small edits that keep a CRM honest, the deal
6
- * board move, and tasks (which core cannot see at all).
7
- *
8
- * One tool per route in supabase/functions/kmhub-api/routes/crm.ts. Nothing here
9
- * deletes anything, and nothing here sends anything to a client of the workspace.
10
- *
11
- * The family contract this file follows is documented in ./README.md.
12
- */
13
- import { z } from 'zod';
14
-
15
- export const FAMILY = 'crm';
16
-
17
- export const TOOLS = [
18
- 'km_find_contact',
19
- 'km_get_client',
20
- 'km_update_client',
21
- 'km_get_deal',
22
- 'km_move_deal_stage',
23
- 'km_set_deal_status',
24
- 'km_list_tasks',
25
- 'km_create_task',
26
- 'km_complete_task',
27
- 'km_log_touch',
28
- ];
29
-
30
- /**
31
- * Every profile. core already puts km_list_clients and km_list_deals in front of
32
- * every caller, so a profile that can list records but cannot open one of them is
33
- * a broken profile, whatever else it was narrowed to.
34
- */
35
- export const PROFILES = ['*'];
36
-
37
- /**
38
- * @param {{ tool: Function }} server guarded registrar (see ./README.md)
39
- * @param {(method: string, path: string, body?: any) => Promise<{ok:boolean,status:number,data:any}>} call
40
- * @param {{ out: Function, qs: Function }} helpers shared response helpers from ../tools.mjs
41
- */
42
- export function register(server, call, { out, qs }) {
43
- server.tool(
44
- 'km_find_contact',
45
- 'THE way to turn a name a person says out loud into a record you can act on. Search contacts by first name, last name, company, or part of an email, or filter by a tag. Reach for this the moment someone says "the ABRA Auto Body draft" or "that magician from the fair" or "the motorsport people" - you cannot act on a name until you have the record. Do NOT reach for km_list_clients to find a named person: that one returns only the most recently added contacts, so anyone added a while ago will not be in it and you will wrongly conclude they do not exist. If this comes back empty, the contact genuinely is not in the workspace under that spelling: say so and offer to create them, rather than guessing. Read only.',
46
- {
47
- q: z.string().optional().describe('A name, company, or part of an email. Partial and case-insensitive, so "abra" finds "ABRA Auto Body & Glass".'),
48
- tag: z.string().optional().describe('Filter by an exact tag, which is how a group like a market segment was labelled. Combine with q to search inside a group.'),
49
- limit: z.number().int().min(1).max(100).optional().describe('Default 25.'),
50
- },
51
- async ({ q, tag, limit }) => out(await call('GET', `/clients/search${qs({ q, tag, limit })}`)),
52
- );
53
-
54
- server.tool(
55
- 'km_get_client',
56
- 'Open ONE client or lead and see everything you would want in front of you before you contact them: their details, the deals they are in, their bookings, anything still open on your side, and the last few conversations anyone recorded. Reach for this whenever you are about to write to someone, quote someone, or answer "what is going on with X", so you are working from their real history rather than from memory. Get the id from km_find_contact. If the record was merged into another contact the answer says so, and the surviving contact is the one to use. Read only: it changes nothing.',
57
- {
58
- client_id: z.string().describe('The client UUID, from km_find_contact.'),
59
- },
60
- async ({ client_id }) => out(await call('GET', `/clients/${encodeURIComponent(String(client_id ?? '').trim())}`)),
61
- );
62
-
63
- server.tool(
64
- 'km_update_client',
65
- 'Correct or fill in the details on an existing client: a new phone number, a spelling, a company name, an address, a note to self. Use it when the person tells you something has changed, or when you spot something wrong while looking at their record. Send only the fields you actually want to change; everything you leave out stays exactly as it is, and passing null to a field clears it. It will NOT touch anything the workspace works out for itself, like their lifetime value, their booking count or their billing links, and it cannot move a client to another workspace. There is no way to delete a client from here. The change is recorded in the AI activity log so the owner can see what you did, but unlike a newly created record it cannot be auto-undone, so read the current details back with km_get_client first if you are not certain.',
66
- {
67
- client_id: z.string().describe('The client UUID, from km_find_contact or km_get_client.'),
68
- first_name: z.string().optional().describe('Cannot be emptied - every contact needs at least a first name.'),
69
- last_name: z.string().nullable().optional(),
70
- company_name: z.string().nullable().optional(),
71
- email: z.string().nullable().optional(),
72
- phone: z.string().nullable().optional(),
73
- job_title: z.string().nullable().optional(),
74
- website: z.string().nullable().optional(),
75
- linkedin_url: z.string().nullable().optional(),
76
- address_line_1: z.string().nullable().optional(),
77
- city: z.string().nullable().optional(),
78
- state: z.string().nullable().optional(),
79
- postal_code: z.string().nullable().optional(),
80
- country_code: z.string().nullable().optional(),
81
- type: z.string().nullable().optional().describe('What kind of contact this is, e.g. individual or company.'),
82
- notes: z.string().nullable().optional().describe('Free notes on the contact. This REPLACES the existing notes, so read them first if you mean to add to them.'),
83
- // tags deliberately absent: a tag can arm an automation that emails or texts
84
- // the client, so it stays a human action in KM Hub. See routes/crm.ts.
85
- },
86
- async ({ client_id, ...fields }) =>
87
- out(await call('PATCH', `/clients/${encodeURIComponent(String(client_id ?? '').trim())}`, fields)),
88
- );
89
-
90
- server.tool(
91
- 'km_get_deal',
92
- 'Open ONE deal from the sales board and see where it stands: the money, the event, who the contact is, every stage it has moved through and when, and the exact list of stages it is allowed to move to next. Reach for this before moving a deal, before quoting on it, or when someone asks what is happening with a particular job. The available_stages in the answer are that board\'s real columns, which may be customised for this workspace, so use them rather than guessing stage names. Read only: it changes nothing.',
93
- {
94
- deal_id: z.string().describe('The deal UUID, from km_list_deals.'),
95
- },
96
- async ({ deal_id }) => out(await call('GET', `/deals/${encodeURIComponent(String(deal_id ?? '').trim())}`)),
97
- );
98
-
99
- server.tool(
100
- 'km_move_deal_stage',
101
- 'Move a deal to a different column on the sales board, for example from Qualifying to Quoting when a price has gone out. The deal\'s open / won / lost standing follows the stage automatically, so the board and the reporting can never disagree. The move is reversible: you can move it back the same way, and every move is kept on the deal\'s own history. IMPORTANT: moving a deal into a stage that marks it WON or LOST is not routine bookkeeping, it changes what this workspace believes about its own money and its own pipeline. Confirm with the person first, in plain words, before you make that particular move, and never infer it from an ambiguous message. Call km_get_deal first to see the stages this board actually has.',
102
- {
103
- deal_id: z.string().describe('The deal UUID, from km_list_deals or km_get_deal.'),
104
- stage: z
105
- .string()
106
- .describe('The stage slug to move into. Use one of the available_stages from km_get_deal. On a standard board those are inbound, qualifying, quoting, proposal_sent, booked, in_progress, completed, lost, abandoned.'),
107
- reason: z
108
- .string()
109
- .optional()
110
- .describe('A short plain-language reason, kept on the deal history so the owner can see why it moved.'),
111
- },
112
- async ({ deal_id, stage, reason }) =>
113
- out(await call('PATCH', `/deals/${encodeURIComponent(String(deal_id ?? '').trim())}/stage`, { stage, reason })),
114
- );
115
-
116
- server.tool(
117
- 'km_set_deal_status',
118
- 'Set whether a deal counts as open, won, lost or abandoned WITHOUT moving it to another column. Use this in the narrow case where the deal is already sitting in the right place on the board but the answer has finally come back, for example a proposal that was declined. In every other case prefer km_move_deal_stage, which keeps the board and the standing in step for you. Marking a deal won or lost changes what this workspace believes about its own money, so confirm with the person first and never infer it. Nothing here is auto-undoable, though the change is kept on the deal history and in the AI activity log.',
119
- {
120
- deal_id: z.string().describe('The deal UUID, from km_list_deals or km_get_deal.'),
121
- status: z
122
- .enum(['open', 'won', 'lost', 'abandoned'])
123
- .describe('open = still live, won = the work is theirs, lost = they said no, abandoned = it went quiet and is not coming back.'),
124
- lost_reason: z.string().optional().describe('Why it was lost, in the person\'s own words. Only kept for lost or abandoned.'),
125
- reason: z.string().optional().describe('A short note kept on the deal history explaining the change.'),
126
- confirm_token: z
127
- .string()
128
- .optional()
129
- .describe(
130
- 'Leave this out on the first call. This action needs an explicit yes from the person you are working with: '
131
- + 'KM Hub answers 409 with a plain description of exactly what would happen, plus a token. Show them that '
132
- + 'description in those words, wait for a real answer, and only then call again with the token and the SAME '
133
- + 'arguments. A yes for one action never authorises a different one.',
134
- ),
135
- },
136
- async ({ deal_id, status, lost_reason, reason, confirm_token }) =>
137
- out(await call('PATCH', `/deals/${encodeURIComponent(String(deal_id ?? '').trim())}/status`, { status, lost_reason, reason, confirm_token })),
138
- );
139
-
140
- server.tool(
141
- 'km_list_tasks',
142
- 'List the jobs on the workspace to-do list. Use it to answer "what do I owe anyone", "what is late", or "what is outstanding on the Henderson wedding". Set overdue to true for the things that were due and are still not done, which is usually what someone actually means when they ask what is late. You can also narrow to one client or one booking. By default it shows the soonest due dates first, and tasks with no date at the end. Read only: it changes nothing.',
143
- {
144
- limit: z.number().int().min(1).max(100).optional().describe('How many to return. Default 25.'),
145
- overdue: z.boolean().optional().describe('True = only work that is past its due date and still not done.'),
146
- status: z
147
- .enum(['pending', 'in_progress', 'completed', 'cancelled'])
148
- .optional()
149
- .describe('Narrow to one state. With overdue this must be pending or in_progress, because finished work cannot be late.'),
150
- client_id: z.string().optional().describe('Only tasks tied to this client (UUID).'),
151
- booking_id: z.string().optional().describe('Only tasks tied to this booking (UUID).'),
152
- },
153
- async ({ limit, overdue, status, client_id, booking_id }) =>
154
- out(
155
- await call(
156
- 'GET',
157
- `/tasks${qs({
158
- limit: limit ?? 25,
159
- overdue: overdue ? 'true' : '',
160
- status,
161
- client_id,
162
- booking_id,
163
- })}`,
164
- ),
165
- ),
166
- );
167
-
168
- server.tool(
169
- 'km_create_task',
170
- 'Add a job to the workspace to-do list, optionally tied to a client or a booking, so a follow-up someone mentioned in passing does not get lost. Good for "chase the deposit on Friday", "send the Hendersons their timeline", "call the venue back". A new task always starts as not done and is never assigned to a particular person, because who does the work is the owner\'s call and not yours. A bare date like 2026-09-14 means the END of that day, so a task due today does not read as late until tomorrow. Creating a task is fully undoable from the AI activity log.',
171
- {
172
- name: z.string().describe('What the task is, in one line. This is what the owner will read on their list.'),
173
- description: z.string().optional().describe('Any detail that will not fit in the name. Up to 20,000 characters, and a longer one is refused with its length rather than stored short.'),
174
- due_date: z.string().optional().describe('YYYY-MM-DD, or a full ISO timestamp when the time of day matters.'),
175
- client_id: z.string().optional().describe('Tie it to a client (UUID), so it shows on their record.'),
176
- booking_id: z.string().optional().describe('Tie it to a booking (UUID), so it shows against that job.'),
177
- },
178
- async (args) => out(await call('POST', '/tasks', args)),
179
- );
180
-
181
- server.tool(
182
- 'km_complete_task',
183
- 'Tick a task off the list once the person tells you it is done. Get the id from km_list_tasks. If it was already ticked off, this says so and changes nothing, so it is safe to call twice. It does not delete the task: completed work stays on the record where it can be seen. There is no way to delete a task from here, and no way to un-complete one, so only call it when you have been told the work is actually finished.',
184
- {
185
- task_id: z.string().describe('The task UUID, from km_list_tasks.'),
186
- },
187
- async ({ task_id }) =>
188
- out(await call('POST', `/tasks/${encodeURIComponent(String(task_id ?? '').trim())}/complete`)),
189
- );
190
-
191
- server.tool(
192
- 'km_log_touch',
193
- 'Write down something that ALREADY happened with a client, so it lands on their timeline in date order next to everything else: a phone call, running into them at a venue, a DM, a card in the post. This is the place for anything that happened off the rails, which the workspace cannot see by itself. Use it whenever the person tells you about a conversation, and quote them rather than summarising, because the detail is the whole point. This tool NEVER contacts anybody: it is a private note in the workspace, not a message, and there is no path from here to one. If you only know the deal, pass deal_id and the note goes on that deal\'s contact; the answer tells you which contact it landed on. Use occurred_at when the conversation happened on a different day from the one you are typing on.',
194
- {
195
- note: z.string().describe('What happened, in the person\'s own words. Free text, and the more specific the better.'),
196
- client_id: z.string().optional().describe('The client UUID this happened with. Give this or deal_id.'),
197
- deal_id: z.string().optional().describe('A deal UUID, when you only know the job. The note goes on that deal\'s contact.'),
198
- channel: z
199
- .enum(['call', 'email', 'text', 'in_person', 'social', 'mail', 'other'])
200
- .optional()
201
- .describe('How it happened. Default other.'),
202
- occurred_at: z
203
- .string()
204
- .optional()
205
- .describe('When it actually happened, YYYY-MM-DD or a full ISO timestamp. Default now. Use it when logging a conversation from a previous day.'),
206
- },
207
- async (args) => out(await call('POST', '/touches', args)),
208
- );
209
- }
1
+ /**
2
+ * Tool family: crm - opening a record, changing it, and keeping the follow-ups.
3
+ *
4
+ * core gives you the lists: clients, deals, bookings. This family gives you the
5
+ * one record behind a list row, the small edits that keep a CRM honest, the deal
6
+ * board move, and tasks (which core cannot see at all).
7
+ *
8
+ * One tool per route in supabase/functions/kmhub-api/routes/crm.ts. Nothing here
9
+ * deletes anything, and nothing here sends anything to a client of the workspace.
10
+ *
11
+ * The family contract this file follows is documented in ./README.md.
12
+ */
13
+ import { z } from 'zod';
14
+
15
+ export const FAMILY = 'crm';
16
+
17
+ export const TOOLS = [
18
+ 'km_find_contact',
19
+ 'km_get_client',
20
+ 'km_update_client',
21
+ 'km_get_deal',
22
+ 'km_move_deal_stage',
23
+ 'km_set_deal_status',
24
+ 'km_list_tasks',
25
+ 'km_create_task',
26
+ 'km_complete_task',
27
+ 'km_log_touch',
28
+ ];
29
+
30
+ /**
31
+ * Every profile. core already puts km_list_clients and km_list_deals in front of
32
+ * every caller, so a profile that can list records but cannot open one of them is
33
+ * a broken profile, whatever else it was narrowed to.
34
+ */
35
+ export const PROFILES = ['*'];
36
+
37
+ /**
38
+ * @param {{ tool: Function }} server guarded registrar (see ./README.md)
39
+ * @param {(method: string, path: string, body?: any) => Promise<{ok:boolean,status:number,data:any}>} call
40
+ * @param {{ out: Function, qs: Function }} helpers shared response helpers from ../tools.mjs
41
+ */
42
+ export function register(server, call, { out, qs }) {
43
+ server.tool(
44
+ 'km_find_contact',
45
+ 'THE way to turn a name a person says out loud into a record you can act on. Search contacts by first name, last name, company, or part of an email, or filter by a tag. Reach for this the moment someone says "the ABRA Auto Body draft" or "that magician from the fair" or "the motorsport people" - you cannot act on a name until you have the record. Do NOT reach for km_list_clients to find a named person: that one returns only the most recently added contacts, so anyone added a while ago will not be in it and you will wrongly conclude they do not exist. If this comes back empty, the contact genuinely is not in the workspace under that spelling: say so and offer to create them, rather than guessing. Read only.',
46
+ {
47
+ q: z.string().optional().describe('A name, company, or part of an email. Partial and case-insensitive, so "abra" finds "ABRA Auto Body & Glass".'),
48
+ tag: z.string().optional().describe('Filter by an exact tag, which is how a group like a market segment was labelled. Combine with q to search inside a group.'),
49
+ limit: z.number().int().min(1).max(100).optional().describe('Default 25.'),
50
+ },
51
+ async ({ q, tag, limit }) => out(await call('GET', `/clients/search${qs({ q, tag, limit })}`)),
52
+ );
53
+
54
+ server.tool(
55
+ 'km_get_client',
56
+ 'Open ONE client or lead and see everything you would want in front of you before you contact them: their details, the deals they are in, their bookings, anything still open on your side, and the last few conversations anyone recorded. Reach for this whenever you are about to write to someone, quote someone, or answer "what is going on with X", so you are working from their real history rather than from memory. Get the id from km_find_contact. If the record was merged into another contact the answer says so, and the surviving contact is the one to use. Read only: it changes nothing.',
57
+ {
58
+ client_id: z.string().describe('The client UUID, from km_find_contact.'),
59
+ },
60
+ async ({ client_id }) => out(await call('GET', `/clients/${encodeURIComponent(String(client_id ?? '').trim())}`)),
61
+ );
62
+
63
+ server.tool(
64
+ 'km_update_client',
65
+ 'Correct or fill in the details on an existing client: a new phone number, a spelling, a company name, an address, a note to self. Use it when the person tells you something has changed, or when you spot something wrong while looking at their record. Send only the fields you actually want to change; everything you leave out stays exactly as it is, and passing null to a field clears it. It will NOT touch anything the workspace works out for itself, like their lifetime value, their booking count or their billing links, and it cannot move a client to another workspace. There is no way to delete a client from here. The change is recorded in the AI activity log so the owner can see what you did, but unlike a newly created record it cannot be auto-undone, so read the current details back with km_get_client first if you are not certain.',
66
+ {
67
+ client_id: z.string().describe('The client UUID, from km_find_contact or km_get_client.'),
68
+ first_name: z.string().optional().describe('Cannot be emptied - every contact needs at least a first name.'),
69
+ last_name: z.string().nullable().optional(),
70
+ company_name: z.string().nullable().optional(),
71
+ email: z.string().nullable().optional(),
72
+ phone: z.string().nullable().optional(),
73
+ job_title: z.string().nullable().optional(),
74
+ website: z.string().nullable().optional(),
75
+ linkedin_url: z.string().nullable().optional(),
76
+ address_line_1: z.string().nullable().optional(),
77
+ city: z.string().nullable().optional(),
78
+ state: z.string().nullable().optional(),
79
+ postal_code: z.string().nullable().optional(),
80
+ country_code: z.string().nullable().optional(),
81
+ type: z.string().nullable().optional().describe('What kind of contact this is, e.g. individual or company.'),
82
+ notes: z.string().nullable().optional().describe('Free notes on the contact. This REPLACES the existing notes, so read them first if you mean to add to them.'),
83
+ // tags deliberately absent: a tag can arm an automation that emails or texts
84
+ // the client, so it stays a human action in KM Hub. See routes/crm.ts.
85
+ },
86
+ async ({ client_id, ...fields }) =>
87
+ out(await call('PATCH', `/clients/${encodeURIComponent(String(client_id ?? '').trim())}`, fields)),
88
+ );
89
+
90
+ server.tool(
91
+ 'km_get_deal',
92
+ 'Open ONE deal from the sales board and see where it stands: the money, the event, who the contact is, every stage it has moved through and when, and the exact list of stages it is allowed to move to next. Reach for this before moving a deal, before quoting on it, or when someone asks what is happening with a particular job. The available_stages in the answer are that board\'s real columns, which may be customised for this workspace, so use them rather than guessing stage names. Read only: it changes nothing.',
93
+ {
94
+ deal_id: z.string().describe('The deal UUID, from km_list_deals.'),
95
+ },
96
+ async ({ deal_id }) => out(await call('GET', `/deals/${encodeURIComponent(String(deal_id ?? '').trim())}`)),
97
+ );
98
+
99
+ server.tool(
100
+ 'km_move_deal_stage',
101
+ 'Move a deal to a different column on the sales board, for example from Qualifying to Quoting when a price has gone out. The deal\'s open / won / lost standing follows the stage automatically, so the board and the reporting can never disagree. The move is reversible: you can move it back the same way, and every move is kept on the deal\'s own history. IMPORTANT: moving a deal into a stage that marks it WON or LOST is not routine bookkeeping, it changes what this workspace believes about its own money and its own pipeline. Confirm with the person first, in plain words, before you make that particular move, and never infer it from an ambiguous message. Call km_get_deal first to see the stages this board actually has.',
102
+ {
103
+ deal_id: z.string().describe('The deal UUID, from km_list_deals or km_get_deal.'),
104
+ stage: z
105
+ .string()
106
+ .describe('The stage slug to move into. Use one of the available_stages from km_get_deal. On a standard board those are inbound, qualifying, quoting, proposal_sent, booked, in_progress, completed, lost, abandoned.'),
107
+ reason: z
108
+ .string()
109
+ .optional()
110
+ .describe('A short plain-language reason, kept on the deal history so the owner can see why it moved.'),
111
+ },
112
+ async ({ deal_id, stage, reason }) =>
113
+ out(await call('PATCH', `/deals/${encodeURIComponent(String(deal_id ?? '').trim())}/stage`, { stage, reason })),
114
+ );
115
+
116
+ server.tool(
117
+ 'km_set_deal_status',
118
+ 'Set whether a deal counts as open, won, lost or abandoned WITHOUT moving it to another column. Use this in the narrow case where the deal is already sitting in the right place on the board but the answer has finally come back, for example a proposal that was declined. In every other case prefer km_move_deal_stage, which keeps the board and the standing in step for you. Marking a deal won or lost changes what this workspace believes about its own money, so confirm with the person first and never infer it. Nothing here is auto-undoable, though the change is kept on the deal history and in the AI activity log.',
119
+ {
120
+ deal_id: z.string().describe('The deal UUID, from km_list_deals or km_get_deal.'),
121
+ status: z
122
+ .enum(['open', 'won', 'lost', 'abandoned'])
123
+ .describe('open = still live, won = the work is theirs, lost = they said no, abandoned = it went quiet and is not coming back.'),
124
+ lost_reason: z.string().optional().describe('Why it was lost, in the person\'s own words. Only kept for lost or abandoned.'),
125
+ reason: z.string().optional().describe('A short note kept on the deal history explaining the change.'),
126
+ confirm_token: z
127
+ .string()
128
+ .optional()
129
+ .describe(
130
+ 'Leave this out on the first call. This action needs an explicit yes from the person you are working with: '
131
+ + 'KM Hub answers 409 with a plain description of exactly what would happen, plus a token. Show them that '
132
+ + 'description in those words, wait for a real answer, and only then call again with the token and the SAME '
133
+ + 'arguments. A yes for one action never authorises a different one.',
134
+ ),
135
+ },
136
+ async ({ deal_id, status, lost_reason, reason, confirm_token }) =>
137
+ out(await call('PATCH', `/deals/${encodeURIComponent(String(deal_id ?? '').trim())}/status`, { status, lost_reason, reason, confirm_token })),
138
+ );
139
+
140
+ server.tool(
141
+ 'km_list_tasks',
142
+ 'List the jobs on the workspace to-do list. Use it to answer "what do I owe anyone", "what is late", or "what is outstanding on the Henderson wedding". Set overdue to true for the things that were due and are still not done, which is usually what someone actually means when they ask what is late. You can also narrow to one client or one booking. By default it shows the soonest due dates first, and tasks with no date at the end. Read only: it changes nothing.',
143
+ {
144
+ limit: z.number().int().min(1).max(100).optional().describe('How many to return. Default 25.'),
145
+ overdue: z.boolean().optional().describe('True = only work that is past its due date and still not done.'),
146
+ status: z
147
+ .enum(['pending', 'in_progress', 'completed', 'cancelled'])
148
+ .optional()
149
+ .describe('Narrow to one state. With overdue this must be pending or in_progress, because finished work cannot be late.'),
150
+ client_id: z.string().optional().describe('Only tasks tied to this client (UUID).'),
151
+ booking_id: z.string().optional().describe('Only tasks tied to this booking (UUID).'),
152
+ },
153
+ async ({ limit, overdue, status, client_id, booking_id }) =>
154
+ out(
155
+ await call(
156
+ 'GET',
157
+ `/tasks${qs({
158
+ limit: limit ?? 25,
159
+ overdue: overdue ? 'true' : '',
160
+ status,
161
+ client_id,
162
+ booking_id,
163
+ })}`,
164
+ ),
165
+ ),
166
+ );
167
+
168
+ server.tool(
169
+ 'km_create_task',
170
+ 'Add a job to the workspace to-do list, optionally tied to a client or a booking, so a follow-up someone mentioned in passing does not get lost. Good for "chase the deposit on Friday", "send the Hendersons their timeline", "call the venue back". A new task always starts as not done and is never assigned to a particular person, because who does the work is the owner\'s call and not yours. A bare date like 2026-09-14 means the END of that day, so a task due today does not read as late until tomorrow. Creating a task is fully undoable from the AI activity log.',
171
+ {
172
+ name: z.string().describe('What the task is, in one line. This is what the owner will read on their list.'),
173
+ description: z.string().optional().describe('Any detail that will not fit in the name. Up to 20,000 characters, and a longer one is refused with its length rather than stored short.'),
174
+ due_date: z.string().optional().describe('YYYY-MM-DD, or a full ISO timestamp when the time of day matters.'),
175
+ client_id: z.string().optional().describe('Tie it to a client (UUID), so it shows on their record.'),
176
+ booking_id: z.string().optional().describe('Tie it to a booking (UUID), so it shows against that job.'),
177
+ },
178
+ async (args) => out(await call('POST', '/tasks', args)),
179
+ );
180
+
181
+ server.tool(
182
+ 'km_complete_task',
183
+ 'Tick a task off the list once the person tells you it is done. Get the id from km_list_tasks. If it was already ticked off, this says so and changes nothing, so it is safe to call twice. It does not delete the task: completed work stays on the record where it can be seen. There is no way to delete a task from here, and no way to un-complete one, so only call it when you have been told the work is actually finished.',
184
+ {
185
+ task_id: z.string().describe('The task UUID, from km_list_tasks.'),
186
+ },
187
+ async ({ task_id }) =>
188
+ out(await call('POST', `/tasks/${encodeURIComponent(String(task_id ?? '').trim())}/complete`)),
189
+ );
190
+
191
+ server.tool(
192
+ 'km_log_touch',
193
+ 'Write down something that ALREADY happened with a client, so it lands on their timeline in date order next to everything else: a phone call, running into them at a venue, a DM, a card in the post. This is the place for anything that happened off the rails, which the workspace cannot see by itself. Use it whenever the person tells you about a conversation, and quote them rather than summarising, because the detail is the whole point. This tool NEVER contacts anybody: it is a private note in the workspace, not a message, and there is no path from here to one. If you only know the deal, pass deal_id and the note goes on that deal\'s contact; the answer tells you which contact it landed on. Use occurred_at when the conversation happened on a different day from the one you are typing on.',
194
+ {
195
+ note: z.string().describe('What happened, in the person\'s own words. Free text, and the more specific the better.'),
196
+ client_id: z.string().optional().describe('The client UUID this happened with. Give this or deal_id.'),
197
+ deal_id: z.string().optional().describe('A deal UUID, when you only know the job. The note goes on that deal\'s contact.'),
198
+ channel: z
199
+ .enum(['call', 'email', 'text', 'in_person', 'social', 'mail', 'other'])
200
+ .optional()
201
+ .describe('How it happened. Default other.'),
202
+ occurred_at: z
203
+ .string()
204
+ .optional()
205
+ .describe('When it actually happened, YYYY-MM-DD or a full ISO timestamp. Default now. Use it when logging a conversation from a previous day.'),
206
+ },
207
+ async (args) => out(await call('POST', '/touches', args)),
208
+ );
209
+ }