@kivimedia/kmhub 2.9.0 → 2.10.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 +36 -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 -109
  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 +134 -134
  34. package/tools/hr.mjs +162 -162
  35. package/tools/knowledge.mjs +125 -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 +134 -134
  57. package/tools.mjs +407 -407
package/tools/dubsado.mjs CHANGED
@@ -1,137 +1,137 @@
1
- /**
2
- * Tool family: dubsado - the Dubsado lead reply loop.
3
- *
4
- * For workspaces whose Dubsado account is wired through the Kivi dubsado-bridge
5
- * (today: Dazzling Balloons). The bridge polls Dubsado every 5 minutes, LeadIQ
6
- * stores each lead and writes an AI reply draft, and the send goes back out
7
- * through Dubsado itself so the reply sits in the real project email thread and
8
- * comes from the business's own sender.
9
- *
10
- * The terminal is the editor: there is no regenerate machinery here. Read the
11
- * draft, rework the wording with the person in chat, send the final text.
12
- *
13
- * The family contract this file follows is documented in ./README.md.
14
- */
15
- export const FAMILY = 'dubsado';
16
-
17
- export const TOOLS = [
18
- 'km_dubsado_status',
19
- 'km_dubsado_leads',
20
- 'km_dubsado_lead',
21
- 'km_dubsado_send_reply',
22
- 'km_dubsado_sync',
23
- ];
24
-
25
- export const PROFILES = ['*'];
26
-
27
- function routeMissing(r) {
28
- return r.status === 404 || r.status === 405 || r.status === 501;
29
- }
30
-
31
- const NOT_SUPPORTED =
32
- 'This workspace does not have the Dubsado connection yet. That is not a fault: every other tool works as ' +
33
- 'normal. Connecting a Dubsado account is done by the Kivi Media team.';
34
-
35
- export function register(server, call, { out, text, qs, z }) {
36
- server.tool(
37
- 'km_dubsado_status',
38
- 'Health check for the Dubsado connection: whether the bridge that syncs leads from Dubsado is alive, when ' +
39
- 'the last sync happened, and the newest lead it has. Call it first in a session about Dubsado leads, and ' +
40
- 'whenever a lead the person expects to see is missing. If bridge.alive is false, say plainly that syncing ' +
41
- 'and sending are down until the Kivi Media team revives the session, and do not attempt a send.',
42
- {},
43
- async () => {
44
- const r = await call('GET', '/dubsado/status');
45
- if (routeMissing(r)) return text(NOT_SUPPORTED, true);
46
- return out(r);
47
- },
48
- );
49
-
50
- server.tool(
51
- 'km_dubsado_leads',
52
- 'The Dubsado leads in this workspace, newest first, each with its reply_state: no_draft_yet, ' +
53
- 'draft_ready_for_review (the AI already wrote a reply nobody approved), approved_awaiting_send, or ' +
54
- 'reply_sent. Use it to answer "any new inquiries?", to find a lead by name, or to sweep for ' +
55
- 'draft_ready_for_review leads that deserve attention. New inquiries land here up to ~5 minutes after ' +
56
- 'they hit Dubsado. This lists; read one lead in full with km_dubsado_lead before judging any draft.',
57
- {
58
- q: z.string().optional().describe('Search by client name, email, or event type.'),
59
- status: z.string().optional().describe('Filter by pipeline stage, e.g. inquiry, qualified, proposal_sent, booked.'),
60
- limit: z.number().int().min(1).max(100).optional().describe('How many leads, default 20.'),
61
- },
62
- async ({ q, status, limit }) => {
63
- const r = await call('GET', `/dubsado/leads${qs({ q, status, limit })}`);
64
- if (routeMissing(r)) return text(NOT_SUPPORTED, true);
65
- return out(r);
66
- },
67
- );
68
-
69
- server.tool(
70
- 'km_dubsado_lead',
71
- 'One Dubsado lead in full: contact details, event info, the form answers they submitted, the whole email ' +
72
- 'thread so far, and every AI reply draft with its state. Accepts the lead_id from km_dubsado_leads (or a ' +
73
- '24 character Dubsado job id). Read this BEFORE proposing any reply: the thread and form answers are the ' +
74
- 'context that makes a reply sound informed instead of canned. Show the newest unsent draft to the person, ' +
75
- 'take their edits in chat, and only then reach for km_dubsado_send_reply.',
76
- {
77
- lead_id: z.string().describe('Lead UUID from km_dubsado_leads, or the Dubsado job id.'),
78
- },
79
- async ({ lead_id }) => {
80
- const r = await call('GET', `/dubsado/leads/${encodeURIComponent(String(lead_id || '').trim())}`);
81
- if (routeMissing(r)) return text(NOT_SUPPORTED, true);
82
- return out(r);
83
- },
84
- );
85
-
86
- server.tool(
87
- 'km_dubsado_send_reply',
88
- 'Send a reply email to a Dubsado lead, through Dubsado, from the business\'s own sender. It lands in the ' +
89
- 'lead\'s real Dubsado project thread and cannot be unsent, so the person must have seen and approved the ' +
90
- 'exact final wording first. Pass draft_id for an existing AI draft, with subject and body carrying the ' +
91
- 'final wording after any edits; or pass just subject + body for a reply written here in the terminal. ' +
92
- 'The recipient is always the lead\'s own email on file and cannot be changed here. Plain text is fine: ' +
93
- 'blank lines become paragraphs. The send is refused for leads whose follow-ups were halted and for leads ' +
94
- 'in a terminal stage like booked or lost. ' +
95
- 'On success, tell the person it went out, to whom, and with what subject.',
96
- {
97
- lead_id: z.string().describe('Lead UUID from km_dubsado_leads, or the Dubsado job id.'),
98
- draft_id: z.string().optional().describe('UUID of the AI draft being approved (from km_dubsado_lead).'),
99
- subject: z.string().optional().describe('Final subject line. Overrides the draft subject when draft_id is given.'),
100
- body: z.string().optional().describe('Final reply text. Overrides the draft text when draft_id is given.'),
101
- confirm_token: z
102
- .string()
103
- .optional()
104
- .describe(
105
- 'Leave this out on the first call. This action needs an explicit yes from the person you are working ' +
106
- 'with: KM Hub answers 409 with a plain description of exactly what would happen, plus a token. Show ' +
107
- 'them that description in those words, wait for a real answer, and only then call again with the ' +
108
- 'token and the SAME arguments. A yes for one action never authorises a different one.',
109
- ),
110
- },
111
- async ({ lead_id, ...rest }) => {
112
- const r = await call(
113
- 'POST',
114
- `/dubsado/leads/${encodeURIComponent(String(lead_id || '').trim())}/send`,
115
- rest,
116
- );
117
- if (routeMissing(r)) return text(NOT_SUPPORTED, true);
118
- return out(r);
119
- },
120
- );
121
-
122
- server.tool(
123
- 'km_dubsado_sync',
124
- 'Ask the bridge to re-pull recent leads from Dubsado now instead of waiting for the next 5 minute poll. ' +
125
- 'Use it when a lead the person can see in Dubsado has not appeared in km_dubsado_leads. Re-pulled leads ' +
126
- 'land within about 5 minutes of this call, so check the list again after that, not immediately. ' +
127
- 'days_back widens how far back the re-pull looks.',
128
- {
129
- days_back: z.number().int().min(1).max(90).optional().describe('Re-pull leads updated in the last N days.'),
130
- },
131
- async ({ days_back }) => {
132
- const r = await call('POST', '/dubsado/sync', days_back ? { days_back } : {});
133
- if (routeMissing(r)) return text(NOT_SUPPORTED, true);
134
- return out(r);
135
- },
136
- );
137
- }
1
+ /**
2
+ * Tool family: dubsado - the Dubsado lead reply loop.
3
+ *
4
+ * For workspaces whose Dubsado account is wired through the Kivi dubsado-bridge
5
+ * (today: Dazzling Balloons). The bridge polls Dubsado every 5 minutes, LeadIQ
6
+ * stores each lead and writes an AI reply draft, and the send goes back out
7
+ * through Dubsado itself so the reply sits in the real project email thread and
8
+ * comes from the business's own sender.
9
+ *
10
+ * The terminal is the editor: there is no regenerate machinery here. Read the
11
+ * draft, rework the wording with the person in chat, send the final text.
12
+ *
13
+ * The family contract this file follows is documented in ./README.md.
14
+ */
15
+ export const FAMILY = 'dubsado';
16
+
17
+ export const TOOLS = [
18
+ 'km_dubsado_status',
19
+ 'km_dubsado_leads',
20
+ 'km_dubsado_lead',
21
+ 'km_dubsado_send_reply',
22
+ 'km_dubsado_sync',
23
+ ];
24
+
25
+ export const PROFILES = ['*'];
26
+
27
+ function routeMissing(r) {
28
+ return r.status === 404 || r.status === 405 || r.status === 501;
29
+ }
30
+
31
+ const NOT_SUPPORTED =
32
+ 'This workspace does not have the Dubsado connection yet. That is not a fault: every other tool works as ' +
33
+ 'normal. Connecting a Dubsado account is done by the Kivi Media team.';
34
+
35
+ export function register(server, call, { out, text, qs, z }) {
36
+ server.tool(
37
+ 'km_dubsado_status',
38
+ 'Health check for the Dubsado connection: whether the bridge that syncs leads from Dubsado is alive, when ' +
39
+ 'the last sync happened, and the newest lead it has. Call it first in a session about Dubsado leads, and ' +
40
+ 'whenever a lead the person expects to see is missing. If bridge.alive is false, say plainly that syncing ' +
41
+ 'and sending are down until the Kivi Media team revives the session, and do not attempt a send.',
42
+ {},
43
+ async () => {
44
+ const r = await call('GET', '/dubsado/status');
45
+ if (routeMissing(r)) return text(NOT_SUPPORTED, true);
46
+ return out(r);
47
+ },
48
+ );
49
+
50
+ server.tool(
51
+ 'km_dubsado_leads',
52
+ 'The Dubsado leads in this workspace, newest first, each with its reply_state: no_draft_yet, ' +
53
+ 'draft_ready_for_review (the AI already wrote a reply nobody approved), approved_awaiting_send, or ' +
54
+ 'reply_sent. Use it to answer "any new inquiries?", to find a lead by name, or to sweep for ' +
55
+ 'draft_ready_for_review leads that deserve attention. New inquiries land here up to ~5 minutes after ' +
56
+ 'they hit Dubsado. This lists; read one lead in full with km_dubsado_lead before judging any draft.',
57
+ {
58
+ q: z.string().optional().describe('Search by client name, email, or event type.'),
59
+ status: z.string().optional().describe('Filter by pipeline stage, e.g. inquiry, qualified, proposal_sent, booked.'),
60
+ limit: z.number().int().min(1).max(100).optional().describe('How many leads, default 20.'),
61
+ },
62
+ async ({ q, status, limit }) => {
63
+ const r = await call('GET', `/dubsado/leads${qs({ q, status, limit })}`);
64
+ if (routeMissing(r)) return text(NOT_SUPPORTED, true);
65
+ return out(r);
66
+ },
67
+ );
68
+
69
+ server.tool(
70
+ 'km_dubsado_lead',
71
+ 'One Dubsado lead in full: contact details, event info, the form answers they submitted, the whole email ' +
72
+ 'thread so far, and every AI reply draft with its state. Accepts the lead_id from km_dubsado_leads (or a ' +
73
+ '24 character Dubsado job id). Read this BEFORE proposing any reply: the thread and form answers are the ' +
74
+ 'context that makes a reply sound informed instead of canned. Show the newest unsent draft to the person, ' +
75
+ 'take their edits in chat, and only then reach for km_dubsado_send_reply.',
76
+ {
77
+ lead_id: z.string().describe('Lead UUID from km_dubsado_leads, or the Dubsado job id.'),
78
+ },
79
+ async ({ lead_id }) => {
80
+ const r = await call('GET', `/dubsado/leads/${encodeURIComponent(String(lead_id || '').trim())}`);
81
+ if (routeMissing(r)) return text(NOT_SUPPORTED, true);
82
+ return out(r);
83
+ },
84
+ );
85
+
86
+ server.tool(
87
+ 'km_dubsado_send_reply',
88
+ 'Send a reply email to a Dubsado lead, through Dubsado, from the business\'s own sender. It lands in the ' +
89
+ 'lead\'s real Dubsado project thread and cannot be unsent, so the person must have seen and approved the ' +
90
+ 'exact final wording first. Pass draft_id for an existing AI draft, with subject and body carrying the ' +
91
+ 'final wording after any edits; or pass just subject + body for a reply written here in the terminal. ' +
92
+ 'The recipient is always the lead\'s own email on file and cannot be changed here. Plain text is fine: ' +
93
+ 'blank lines become paragraphs. The send is refused for leads whose follow-ups were halted and for leads ' +
94
+ 'in a terminal stage like booked or lost. ' +
95
+ 'On success, tell the person it went out, to whom, and with what subject.',
96
+ {
97
+ lead_id: z.string().describe('Lead UUID from km_dubsado_leads, or the Dubsado job id.'),
98
+ draft_id: z.string().optional().describe('UUID of the AI draft being approved (from km_dubsado_lead).'),
99
+ subject: z.string().optional().describe('Final subject line. Overrides the draft subject when draft_id is given.'),
100
+ body: z.string().optional().describe('Final reply text. Overrides the draft text when draft_id is given.'),
101
+ confirm_token: z
102
+ .string()
103
+ .optional()
104
+ .describe(
105
+ 'Leave this out on the first call. This action needs an explicit yes from the person you are working ' +
106
+ 'with: KM Hub answers 409 with a plain description of exactly what would happen, plus a token. Show ' +
107
+ 'them that description in those words, wait for a real answer, and only then call again with the ' +
108
+ 'token and the SAME arguments. A yes for one action never authorises a different one.',
109
+ ),
110
+ },
111
+ async ({ lead_id, ...rest }) => {
112
+ const r = await call(
113
+ 'POST',
114
+ `/dubsado/leads/${encodeURIComponent(String(lead_id || '').trim())}/send`,
115
+ rest,
116
+ );
117
+ if (routeMissing(r)) return text(NOT_SUPPORTED, true);
118
+ return out(r);
119
+ },
120
+ );
121
+
122
+ server.tool(
123
+ 'km_dubsado_sync',
124
+ 'Ask the bridge to re-pull recent leads from Dubsado now instead of waiting for the next 5 minute poll. ' +
125
+ 'Use it when a lead the person can see in Dubsado has not appeared in km_dubsado_leads. Re-pulled leads ' +
126
+ 'land within about 5 minutes of this call, so check the list again after that, not immediately. ' +
127
+ 'days_back widens how far back the re-pull looks.',
128
+ {
129
+ days_back: z.number().int().min(1).max(90).optional().describe('Re-pull leads updated in the last N days.'),
130
+ },
131
+ async ({ days_back }) => {
132
+ const r = await call('POST', '/dubsado/sync', days_back ? { days_back } : {});
133
+ if (routeMissing(r)) return text(NOT_SUPPORTED, true);
134
+ return out(r);
135
+ },
136
+ );
137
+ }
package/tools/exports.mjs CHANGED
@@ -1,128 +1,128 @@
1
- /**
2
- * Tool family: exports - getting your own data back out, from a terminal.
3
- *
4
- * This family exists because of one email. A customer with three workspaces asked
5
- * for "a full export of all my data, including the most recent", said the system
6
- * was hard to navigate, and said he could not work out how to see his invoices.
7
- * Everything he asked for was already in KM Hub and already exportable, and there
8
- * was still no way to answer him without telling him to go and click through a
9
- * screen he had just told us he could not find. A person asking for their own
10
- * records should never be handed a navigation problem.
11
- *
12
- * Four tools:
13
- * km_export_data one dataset, csv or xlsx
14
- * km_export_everything the whole workspace, one ZIP of readable CSVs
15
- * km_export_status how far an export got, and its link once ready
16
- * km_list_exports what has been exported lately
17
- *
18
- * These do not build files. They queue the same job the Export Data screen
19
- * queues, and the same worker builds it, so an export started here and an export
20
- * started in the browser are byte-for-byte the same file.
21
- *
22
- * 🚨 A DOWNLOAD LINK IS A CREDENTIAL. The signed URL needs no login and lives for
23
- * seven days. Hand it to the person who asked, use it to fetch the file, and do
24
- * not write it into a document, a commit, a ticket or a chat log that outlives
25
- * the download.
26
- *
27
- * No PROFILES export on purpose. Exporting is not the job of the narrow money,
28
- * outreach or content profiles, and a family that pins itself into every profile
29
- * makes the profiles meaningless. It ships in `full`, which is the default.
30
- *
31
- * The family contract this file follows is documented in ./README.md.
32
- */
33
- import { z } from 'zod';
34
-
35
- export const FAMILY = 'exports';
36
-
37
- export const TOOLS = [
38
- 'km_export_data',
39
- 'km_export_everything',
40
- 'km_export_status',
41
- 'km_list_exports',
42
- ];
43
-
44
- const LINK_WARNING =
45
- 'The download link that comes back is a signed URL: anyone holding it can fetch the file without signing in, and it expires after seven days. Give it to the person who asked for it and do not paste it anywhere it will be stored.';
46
-
47
- const NOT_INSTANT =
48
- 'The file is built in the background. This returns an export id straight away; call km_export_status with that id until it says ready, which is usually a few seconds and can be a couple of minutes for a large workspace.';
49
-
50
- /**
51
- * @param {{ tool: Function }} server guarded registrar (see ./README.md)
52
- * @param {(method: string, path: string, body?: any) => Promise<{ok:boolean,status:number,data:any}>} call
53
- * @param {{ out: Function, text: Function, qs: Function }} helpers
54
- */
55
- export function register(server, call, { out, qs }) {
56
- server.tool(
57
- 'km_export_data',
58
- 'Export one set of records from this workspace to a spreadsheet file: invoices, bookings, clients, payments, quotes, leads, expenses, gigs, venues, vendors, equipment, team members, or the outreach tables. ' +
59
- 'Reach for this whenever someone asks to be sent, given, or shown their data outside KM Hub: "send me my invoices", "export the client list", "I need last year in a spreadsheet", "can I get all of this out", "my accountant wants the payments". ' +
60
- 'It exports every column of the chosen dataset. Narrow it by date with date_from and date_to when the person named a period, and leave both off when they asked for everything. ' +
61
- 'If they want the whole workspace rather than one dataset, use km_export_everything instead. ' +
62
- NOT_INSTANT + ' ' + LINK_WARNING,
63
- {
64
- dataset: z
65
- .string()
66
- .describe(
67
- 'What to export. One of: invoices, bookings, clients, payments, quotes, inquiries, expenses, gigs, venues, vendors, equipment_items, kmhub_export_team_members, outreach_campaigns, outreach_campaign_prospect_status, outreach_replies, outreach_drafts, booking_drafts. Everyday words are understood too: events, jobs and projects mean bookings, contacts and customers mean clients, bills means invoices, leads and enquiries mean inquiries, equipment, team and crew are understood.',
68
- ),
69
- format: z
70
- .enum(['csv', 'xlsx'])
71
- .optional()
72
- .describe('csv (the default, opens anywhere) or xlsx for Excel.'),
73
- date_from: z.string().optional().describe('Only records from this date onward, as YYYY-MM-DD. Leave off for everything.'),
74
- date_to: z.string().optional().describe('Only records before this date, as YYYY-MM-DD. Leave off for everything.'),
75
- date_field: z
76
- .string()
77
- .optional()
78
- .describe('Which date the range applies to. Defaults to created_at. For bookings, event_date is usually what a person means by "last year".'),
79
- label: z.string().optional().describe('A name for this export, shown in the export history. Say what it is, for instance "2025 invoices for the accountant".'),
80
- },
81
- async ({ dataset, format, date_from, date_to, date_field, label }) =>
82
- out(await call('POST', '/exports', { dataset, format, date_from, date_to, date_field, label })),
83
- );
84
-
85
- server.tool(
86
- 'km_export_everything',
87
- 'Export the ENTIRE workspace as one ZIP file: every booking, lead, deal, client, quote, invoice, payment, venue, vendor, expense, gig, team member, email history, outreach record and Booking Brain artifact, each as its own readable CSV inside the archive. ' +
88
- 'This is the one to reach for when someone asks for "all my data", "a full export", "everything you have on us", or is leaving and wants their records. ' +
89
- 'Prefer km_export_data when they named one thing: a person who asked for their invoices does not want a forty-file archive to dig through. ' +
90
- 'This is a confirmed action. The first call comes back with a sentence describing exactly what will be packaged and a confirm_token. Put that sentence to the person, wait for an explicit yes, then call again with the same inputs plus that token. Never send the token without asking. ' +
91
- NOT_INSTANT + ' ' + LINK_WARNING,
92
- {
93
- label: z.string().optional().describe('A name for this export, shown in the export history.'),
94
- confirm_token: z
95
- .string()
96
- .min(1)
97
- .max(4096)
98
- .optional()
99
- .describe('Only ever the exact token returned in the 409 response, and only after the person has said yes to the sentence that came with it.'),
100
- },
101
- async ({ label, confirm_token }) =>
102
- out(await call('POST', '/exports/everything', { label, ...(confirm_token ? { confirm_token } : {}) })),
103
- );
104
-
105
- server.tool(
106
- 'km_export_status',
107
- 'Check one export: whether it is still building, what it is doing right now, how many rows it has read, and once it is ready the file size, the row count and the download link. ' +
108
- 'Call this after km_export_data or km_export_everything, using the export_id they returned. If it is still building, say what it is doing rather than just "working on it", and check again in a few seconds. ' +
109
- 'A failed export comes back with the reason in error_message, which is usually a date range that matched too many rows. ' +
110
- LINK_WARNING,
111
- {
112
- export_id: z.string().describe('The export_id returned when the export was started.'),
113
- },
114
- async ({ export_id }) => out(await call('GET', `/exports/${encodeURIComponent(export_id)}`)),
115
- );
116
-
117
- server.tool(
118
- 'km_list_exports',
119
- 'List the exports built from this workspace recently: what was exported, when, in what format, how big it was and whether it is still available. ' +
120
- 'Useful when someone asks "did I already pull that", "what did we send the accountant", or when an earlier export id has been lost. ' +
121
- 'Download links are deliberately not included here. Ask for one export by id with km_export_status to get its link. ' +
122
- 'This tool only reads. It cannot start or delete an export.',
123
- {
124
- limit: z.number().int().min(1).max(100).optional().describe('How many to list. Defaults to 20.'),
125
- },
126
- async ({ limit }) => out(await call('GET', `/exports${qs({ limit })}`)),
127
- );
128
- }
1
+ /**
2
+ * Tool family: exports - getting your own data back out, from a terminal.
3
+ *
4
+ * This family exists because of one email. A customer with three workspaces asked
5
+ * for "a full export of all my data, including the most recent", said the system
6
+ * was hard to navigate, and said he could not work out how to see his invoices.
7
+ * Everything he asked for was already in KM Hub and already exportable, and there
8
+ * was still no way to answer him without telling him to go and click through a
9
+ * screen he had just told us he could not find. A person asking for their own
10
+ * records should never be handed a navigation problem.
11
+ *
12
+ * Four tools:
13
+ * km_export_data one dataset, csv or xlsx
14
+ * km_export_everything the whole workspace, one ZIP of readable CSVs
15
+ * km_export_status how far an export got, and its link once ready
16
+ * km_list_exports what has been exported lately
17
+ *
18
+ * These do not build files. They queue the same job the Export Data screen
19
+ * queues, and the same worker builds it, so an export started here and an export
20
+ * started in the browser are byte-for-byte the same file.
21
+ *
22
+ * 🚨 A DOWNLOAD LINK IS A CREDENTIAL. The signed URL needs no login and lives for
23
+ * seven days. Hand it to the person who asked, use it to fetch the file, and do
24
+ * not write it into a document, a commit, a ticket or a chat log that outlives
25
+ * the download.
26
+ *
27
+ * No PROFILES export on purpose. Exporting is not the job of the narrow money,
28
+ * outreach or content profiles, and a family that pins itself into every profile
29
+ * makes the profiles meaningless. It ships in `full`, which is the default.
30
+ *
31
+ * The family contract this file follows is documented in ./README.md.
32
+ */
33
+ import { z } from 'zod';
34
+
35
+ export const FAMILY = 'exports';
36
+
37
+ export const TOOLS = [
38
+ 'km_export_data',
39
+ 'km_export_everything',
40
+ 'km_export_status',
41
+ 'km_list_exports',
42
+ ];
43
+
44
+ const LINK_WARNING =
45
+ 'The download link that comes back is a signed URL: anyone holding it can fetch the file without signing in, and it expires after seven days. Give it to the person who asked for it and do not paste it anywhere it will be stored.';
46
+
47
+ const NOT_INSTANT =
48
+ 'The file is built in the background. This returns an export id straight away; call km_export_status with that id until it says ready, which is usually a few seconds and can be a couple of minutes for a large workspace.';
49
+
50
+ /**
51
+ * @param {{ tool: Function }} server guarded registrar (see ./README.md)
52
+ * @param {(method: string, path: string, body?: any) => Promise<{ok:boolean,status:number,data:any}>} call
53
+ * @param {{ out: Function, text: Function, qs: Function }} helpers
54
+ */
55
+ export function register(server, call, { out, qs }) {
56
+ server.tool(
57
+ 'km_export_data',
58
+ 'Export one set of records from this workspace to a spreadsheet file: invoices, bookings, clients, payments, quotes, leads, expenses, gigs, venues, vendors, equipment, team members, or the outreach tables. ' +
59
+ 'Reach for this whenever someone asks to be sent, given, or shown their data outside KM Hub: "send me my invoices", "export the client list", "I need last year in a spreadsheet", "can I get all of this out", "my accountant wants the payments". ' +
60
+ 'It exports every column of the chosen dataset. Narrow it by date with date_from and date_to when the person named a period, and leave both off when they asked for everything. ' +
61
+ 'If they want the whole workspace rather than one dataset, use km_export_everything instead. ' +
62
+ NOT_INSTANT + ' ' + LINK_WARNING,
63
+ {
64
+ dataset: z
65
+ .string()
66
+ .describe(
67
+ 'What to export. One of: invoices, bookings, clients, payments, quotes, inquiries, expenses, gigs, venues, vendors, equipment_items, kmhub_export_team_members, outreach_campaigns, outreach_campaign_prospect_status, outreach_replies, outreach_drafts, booking_drafts. Everyday words are understood too: events, jobs and projects mean bookings, contacts and customers mean clients, bills means invoices, leads and enquiries mean inquiries, equipment, team and crew are understood.',
68
+ ),
69
+ format: z
70
+ .enum(['csv', 'xlsx'])
71
+ .optional()
72
+ .describe('csv (the default, opens anywhere) or xlsx for Excel.'),
73
+ date_from: z.string().optional().describe('Only records from this date onward, as YYYY-MM-DD. Leave off for everything.'),
74
+ date_to: z.string().optional().describe('Only records before this date, as YYYY-MM-DD. Leave off for everything.'),
75
+ date_field: z
76
+ .string()
77
+ .optional()
78
+ .describe('Which date the range applies to. Defaults to created_at. For bookings, event_date is usually what a person means by "last year".'),
79
+ label: z.string().optional().describe('A name for this export, shown in the export history. Say what it is, for instance "2025 invoices for the accountant".'),
80
+ },
81
+ async ({ dataset, format, date_from, date_to, date_field, label }) =>
82
+ out(await call('POST', '/exports', { dataset, format, date_from, date_to, date_field, label })),
83
+ );
84
+
85
+ server.tool(
86
+ 'km_export_everything',
87
+ 'Export the ENTIRE workspace as one ZIP file: every booking, lead, deal, client, quote, invoice, payment, venue, vendor, expense, gig, team member, email history, outreach record and Booking Brain artifact, each as its own readable CSV inside the archive. ' +
88
+ 'This is the one to reach for when someone asks for "all my data", "a full export", "everything you have on us", or is leaving and wants their records. ' +
89
+ 'Prefer km_export_data when they named one thing: a person who asked for their invoices does not want a forty-file archive to dig through. ' +
90
+ 'This is a confirmed action. The first call comes back with a sentence describing exactly what will be packaged and a confirm_token. Put that sentence to the person, wait for an explicit yes, then call again with the same inputs plus that token. Never send the token without asking. ' +
91
+ NOT_INSTANT + ' ' + LINK_WARNING,
92
+ {
93
+ label: z.string().optional().describe('A name for this export, shown in the export history.'),
94
+ confirm_token: z
95
+ .string()
96
+ .min(1)
97
+ .max(4096)
98
+ .optional()
99
+ .describe('Only ever the exact token returned in the 409 response, and only after the person has said yes to the sentence that came with it.'),
100
+ },
101
+ async ({ label, confirm_token }) =>
102
+ out(await call('POST', '/exports/everything', { label, ...(confirm_token ? { confirm_token } : {}) })),
103
+ );
104
+
105
+ server.tool(
106
+ 'km_export_status',
107
+ 'Check one export: whether it is still building, what it is doing right now, how many rows it has read, and once it is ready the file size, the row count and the download link. ' +
108
+ 'Call this after km_export_data or km_export_everything, using the export_id they returned. If it is still building, say what it is doing rather than just "working on it", and check again in a few seconds. ' +
109
+ 'A failed export comes back with the reason in error_message, which is usually a date range that matched too many rows. ' +
110
+ LINK_WARNING,
111
+ {
112
+ export_id: z.string().describe('The export_id returned when the export was started.'),
113
+ },
114
+ async ({ export_id }) => out(await call('GET', `/exports/${encodeURIComponent(export_id)}`)),
115
+ );
116
+
117
+ server.tool(
118
+ 'km_list_exports',
119
+ 'List the exports built from this workspace recently: what was exported, when, in what format, how big it was and whether it is still available. ' +
120
+ 'Useful when someone asks "did I already pull that", "what did we send the accountant", or when an earlier export id has been lost. ' +
121
+ 'Download links are deliberately not included here. Ask for one export by id with km_export_status to get its link. ' +
122
+ 'This tool only reads. It cannot start or delete an export.',
123
+ {
124
+ limit: z.number().int().min(1).max(100).optional().describe('How many to list. Defaults to 20.'),
125
+ },
126
+ async ({ limit }) => out(await call('GET', `/exports${qs({ limit })}`)),
127
+ );
128
+ }