@kivimedia/kmhub 2.0.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.
- package/README.md +169 -0
- package/bin/kmhub.mjs +883 -0
- package/index.mjs +55 -0
- package/package.json +51 -0
- package/remote.mjs +213 -0
- package/tools/briefing.mjs +91 -0
- package/tools/calendar.mjs +161 -0
- package/tools/core.mjs +223 -0
- package/tools/crm.mjs +200 -0
- package/tools/knowledge.mjs +124 -0
- package/tools/meta.mjs +245 -0
- package/tools/money.mjs +197 -0
- package/tools/outreach.mjs +215 -0
- package/tools/plays.mjs +244 -0
- package/tools/sourcing.mjs +220 -0
- package/tools.mjs +349 -0
package/tools/core.mjs
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool family: core - orientation plus the CRM essentials.
|
|
3
|
+
*
|
|
4
|
+
* These are the original ten kmhub-mcp tools (server 1.2.0), moved here verbatim
|
|
5
|
+
* when the tool set was split into families. Tool names, descriptions, zod schemas,
|
|
6
|
+
* call paths and registration order are all unchanged, so a client that was already
|
|
7
|
+
* connected sees exactly the same ten tools it saw before the split.
|
|
8
|
+
*
|
|
9
|
+
* The family contract this file follows is documented in ./README.md.
|
|
10
|
+
*/
|
|
11
|
+
import { z } from 'zod';
|
|
12
|
+
|
|
13
|
+
export const FAMILY = 'core';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Order preserved from the original tools.mjs TOOL_NAMES export, so the health
|
|
17
|
+
* endpoint and the startup log keep printing the same list in the same order.
|
|
18
|
+
*/
|
|
19
|
+
export const TOOLS = [
|
|
20
|
+
'km_me',
|
|
21
|
+
'km_waiting',
|
|
22
|
+
'km_list_clients',
|
|
23
|
+
'km_create_lead',
|
|
24
|
+
'km_list_deals',
|
|
25
|
+
'km_list_events',
|
|
26
|
+
'km_list_bookings',
|
|
27
|
+
'km_create_inquiry',
|
|
28
|
+
'km_create_booking',
|
|
29
|
+
'km_create_outreach_draft',
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
// Orientation and the CRM floor belong in every profile: you always need to know
|
|
33
|
+
// whose workspace this is and what is waiting, whatever else you loaded.
|
|
34
|
+
export const PROFILES = ['*'];
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {{ tool: Function }} server guarded registrar (see ./README.md)
|
|
38
|
+
* @param {(method: string, path: string, body?: any) => Promise<{ok:boolean,status:number,data:any}>} call
|
|
39
|
+
* @param {{ out: Function }} helpers shared response helpers from ../tools.mjs
|
|
40
|
+
*/
|
|
41
|
+
export function register(server, call, { out }) {
|
|
42
|
+
server.tool(
|
|
43
|
+
'km_me',
|
|
44
|
+
'Which KM Hub workspace this connection actually points at: its id, the business name on it, and the plan it is on. ' +
|
|
45
|
+
'Call it once at the start of a session so you know whose business you are speaking about, and call it again the moment anything looks wrong - an empty client list, a diary with nothing in it, figures that do not match what the user is describing - because the commonest cause of all three is a key pointing at a different workspace than the one they mean. ' +
|
|
46
|
+
'It does not tell you which human is at the keyboard, it does not list the users on the account, and the plan it reports is the subscription, not how much of this month\'s allowance is left. ' +
|
|
47
|
+
'It is cheap, it reads, and it changes nothing.',
|
|
48
|
+
{},
|
|
49
|
+
async () => out(await call('GET', '/me')),
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
server.tool(
|
|
53
|
+
'km_waiting',
|
|
54
|
+
'Raw gate counts and nothing else: how many approvals, new enquiries, open tasks, unpaid invoices and unsigned contracts are sitting in the workspace right now. It is a gauge, not an answer. ' +
|
|
55
|
+
'Do NOT reach for this on the broad question. If the user says "what needs me today", "catch me up", "where are we", "what should I do first", "anything urgent", "what did I miss", or opens a session with no specific task, km_briefing is the tool: it returns these same counts AND the actual items behind them, in one list ranked in the order a person should deal with them, each with the reason it is there. Answering the broad question from a set of counts means guessing which of the numbers mattered, which is exactly the guess km_briefing exists to remove. ' +
|
|
56
|
+
'Reach for km_waiting only when you genuinely want the bare numbers on their own: to say whether anything has changed since earlier in the same conversation, or when km_briefing has told you it is not available on this workspace. ' +
|
|
57
|
+
'A count tells you something is waiting. It never tells you which one is worth doing first, and it carries no client, no amount, no date and no id, so nothing here can be opened or acted on without a second call. Read only.',
|
|
58
|
+
{},
|
|
59
|
+
async () => out(await call('GET', '/waiting')),
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
server.tool(
|
|
63
|
+
'km_list_clients',
|
|
64
|
+
'The most recently ADDED clients and leads, newest first. This is a recency window, NOT a search. ' +
|
|
65
|
+
'It walks back from the newest record and stops, so a contact added a year ago will not appear whatever you set limit to, and even at the maximum of 100 it shows the newest 100 and nothing behind them. ' +
|
|
66
|
+
'That means it CANNOT answer "do I have X", "find me the person at Y" or "what is that magician called", and treating a miss here as proof somebody is not in the workspace is the easiest way to tell a user something false about their own business. km_find_contact is the tool for finding a named person, a company or part of an email, and it searches all of them rather than the newest few. ' +
|
|
67
|
+
'Reach for km_list_clients only for the genuinely recency-shaped question: who has come in lately, what do the last few enquiries look like, has anything landed since we spoke. ' +
|
|
68
|
+
'It returns the record as it stands, not the history behind it: km_get_client opens one in full. Read only.',
|
|
69
|
+
{ limit: z.number().int().min(1).max(100).optional() },
|
|
70
|
+
async ({ limit }) => out(await call('GET', `/clients?limit=${limit ?? 25}`)),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
server.tool(
|
|
74
|
+
'km_create_lead',
|
|
75
|
+
'Put a person on file as a contact with nothing attached to them: no enquiry, no deal, no date, no money. ' +
|
|
76
|
+
'Reach for it when the user has met somebody and wants them kept - a card from a convention, a name off a phone call - and there is nothing being sold yet. ' +
|
|
77
|
+
'Search with km_find_contact FIRST. This tool will happily create a second Sarah Jones alongside the one already there, and a split contact is worse than no contact: half her history sits on a record nobody opens. ' +
|
|
78
|
+
'It is the wrong tool when there is actually something to sell. If the person is asking about a date, use km_create_inquiry, which creates the contact AND opens the pipeline deal behind it. If the gig is already agreed, use km_create_booking. ' +
|
|
79
|
+
'Every field is optional, but a contact with no email and no phone can never be written to, so ask for one rather than saving a bare name. ' +
|
|
80
|
+
'This WRITES: it needs the API key to carry the write scope, and it lands in the AI activity log where the owner can see and undo it.',
|
|
81
|
+
{
|
|
82
|
+
first_name: z.string().optional(),
|
|
83
|
+
last_name: z.string().optional(),
|
|
84
|
+
company: z.string().optional(),
|
|
85
|
+
email: z.string().optional(),
|
|
86
|
+
phone: z.string().optional(),
|
|
87
|
+
notes: z.string().optional(),
|
|
88
|
+
},
|
|
89
|
+
async (args) => out(await call('POST', '/clients', args)),
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
server.tool(
|
|
93
|
+
'km_list_bookings',
|
|
94
|
+
'The real jobs: the bookings on this business, each with its client, its date, its venue and the money on it. ' +
|
|
95
|
+
'Reach for it for the book of business rather than for the diary - what has been booked lately, what is still only pending a confirmation, every gig inside a date range so you can see the shape of a season, what this business has actually done before. ' +
|
|
96
|
+
'from and to filter on the EVENT date, not on when the booking was taken, so from today forward is the work ahead and to yesterday is the history. Use status to separate the confirmed work from what is still pending. ' +
|
|
97
|
+
'It caps at 100 rows and does not report the true total, so a busy year needs a narrower window rather than a bigger limit, and if you asked for 100 and got 100, say the picture is partial instead of presenting a slice as the whole book. ' +
|
|
98
|
+
'It tells you what is BOOKED. It says nothing about whether a new date could be sold, because a free-looking day can still be blocked, out of area or already pencilled in by a live lead: km_check_availability is the only honest answer to that question. km_get_booking opens one booking in full, where that tool is loaded. Read only.',
|
|
99
|
+
{
|
|
100
|
+
limit: z.number().int().min(1).max(100).optional(),
|
|
101
|
+
status: z.string().optional().describe('e.g. confirmed, pending'),
|
|
102
|
+
from: z.string().optional().describe('Earliest event date, YYYY-MM-DD.'),
|
|
103
|
+
to: z.string().optional().describe('Latest event date, YYYY-MM-DD.'),
|
|
104
|
+
},
|
|
105
|
+
async ({ limit, status, from, to }) => {
|
|
106
|
+
const q = new URLSearchParams();
|
|
107
|
+
q.set('limit', String(limit ?? 25));
|
|
108
|
+
if (status) q.set('status', status);
|
|
109
|
+
if (from) q.set('from', from);
|
|
110
|
+
if (to) q.set('to', to);
|
|
111
|
+
return out(await call('GET', `/bookings?${q.toString()}`));
|
|
112
|
+
},
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
server.tool(
|
|
116
|
+
'km_list_deals',
|
|
117
|
+
'The pipeline: the live opportunities in this workspace and how far each one has got, newest first. ' +
|
|
118
|
+
'Reach for it for the shape of the funnel - what is open, what is stuck in quoting, what has been won, what was lost - and to get the deal_id that km_get_deal, km_create_outreach_draft and a play run all need before they can attach anything to the right opportunity. ' +
|
|
119
|
+
'stage narrows to one column of the board. status is the coarse open, won, lost, abandoned split. status lost, then km_get_deal on the big ones, is how you read WHY work was lost, which is almost always worth more than the count: a large deal lost on timing is a live target, a large deal lost on price is evidence about the rate. ' +
|
|
120
|
+
'It caps at 100 rows and does not report the true total, so if you asked for 100 and got 100, say the pipeline you are describing is partial. ' +
|
|
121
|
+
'It cannot search by a client\'s name: find the person with km_find_contact first, then work from their record. Read only.',
|
|
122
|
+
{
|
|
123
|
+
limit: z.number().int().min(1).max(100).optional(),
|
|
124
|
+
stage: z
|
|
125
|
+
.enum([
|
|
126
|
+
'inbound',
|
|
127
|
+
'qualifying',
|
|
128
|
+
'quoting',
|
|
129
|
+
'proposal_sent',
|
|
130
|
+
'booked',
|
|
131
|
+
'in_progress',
|
|
132
|
+
'completed',
|
|
133
|
+
'lost',
|
|
134
|
+
'abandoned',
|
|
135
|
+
])
|
|
136
|
+
.optional(),
|
|
137
|
+
status: z.enum(['open', 'won', 'lost', 'abandoned']).optional(),
|
|
138
|
+
},
|
|
139
|
+
async ({ limit, stage, status }) => {
|
|
140
|
+
const q = new URLSearchParams();
|
|
141
|
+
q.set('limit', String(limit ?? 25));
|
|
142
|
+
if (stage) q.set('stage', stage);
|
|
143
|
+
if (status) q.set('status', status);
|
|
144
|
+
return out(await call('GET', `/deals?${q.toString()}`));
|
|
145
|
+
},
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
server.tool(
|
|
149
|
+
'km_list_events',
|
|
150
|
+
'The raw contents of the KM Hub calendar over a date window, in start order: the gigs and everything else that has been put in the diary alongside them. ' +
|
|
151
|
+
'Always set from and to. With no window it returns whatever is most recent, which is almost never the question anybody asked. ' +
|
|
152
|
+
'Reach for it when the user wants the literal contents of a range: what is in September, is there anything at all on that weekend, how full does next month look. ' +
|
|
153
|
+
'For the ordinary "what have I got on" question, km_my_schedule is the better answer wherever it is loaded, because it groups the days and names the client, venue, times and fee on each gig rather than handing back a flat list. For whether ONE date can be sold, km_check_availability is the only honest source: this tool shows what already exists, and an empty-looking day is not the same as an available one. ' +
|
|
154
|
+
'Read only: it never adds, moves or cancels anything in the diary.',
|
|
155
|
+
{
|
|
156
|
+
from: z.string().optional().describe('Earliest start date, YYYY-MM-DD.'),
|
|
157
|
+
to: z.string().optional().describe('Latest start date, YYYY-MM-DD.'),
|
|
158
|
+
limit: z.number().int().min(1).max(200).optional(),
|
|
159
|
+
},
|
|
160
|
+
async ({ from, to, limit }) => {
|
|
161
|
+
const q = new URLSearchParams();
|
|
162
|
+
q.set('limit', String(limit ?? 50));
|
|
163
|
+
if (from) q.set('from', from);
|
|
164
|
+
if (to) q.set('to', to);
|
|
165
|
+
return out(await call('GET', `/events?${q.toString()}`));
|
|
166
|
+
},
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
server.tool(
|
|
170
|
+
'km_create_outreach_draft',
|
|
171
|
+
'Queue an outreach message as a DRAFT in KM Hub. This tool NEVER sends anything - the draft lands in the Approval Queue and a human approves it before any send happens. Audited in the AI activity log.',
|
|
172
|
+
{
|
|
173
|
+
body: z.string().describe('The message text (required).'),
|
|
174
|
+
subject: z.string().optional().describe('Subject line (email channel).'),
|
|
175
|
+
channel: z.enum(['email', 'linkedin', 'ig', 'sms_whatsapp']).optional().describe('Default email.'),
|
|
176
|
+
deal_id: z.string().optional().describe('UUID of the deal this draft belongs to (see km_list_deals).'),
|
|
177
|
+
contact_id: z.string().optional().describe('UUID of the client/contact (see km_find_contact).'),
|
|
178
|
+
},
|
|
179
|
+
async (args) => out(await call('POST', '/outreach-drafts', args)),
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
server.tool(
|
|
183
|
+
'km_create_inquiry',
|
|
184
|
+
'Log a new inquiry (lead + enquiry) in KM Hub - creates the client if needed and opens a pipeline deal. Audited and undoable from the AI activity log.',
|
|
185
|
+
{
|
|
186
|
+
client_name: z.string().describe('Full name of the person enquiring.'),
|
|
187
|
+
client_email: z.string().optional(),
|
|
188
|
+
client_phone: z.string().optional(),
|
|
189
|
+
event_type: z.string().optional().describe('e.g. wedding, corporate, birthday.'),
|
|
190
|
+
event_date: z.string().optional().describe('YYYY-MM-DD.'),
|
|
191
|
+
event_start_time: z.string().optional().describe('HH:MM 24h.'),
|
|
192
|
+
event_end_time: z.string().optional().describe('HH:MM 24h.'),
|
|
193
|
+
venue_name: z.string().optional(),
|
|
194
|
+
guest_count: z.number().optional(),
|
|
195
|
+
budget_cents: z.number().optional().describe('Budget in CENTS.'),
|
|
196
|
+
notes: z.string().optional(),
|
|
197
|
+
},
|
|
198
|
+
async (args) => out(await call('POST', '/inquiries', args)),
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
server.tool(
|
|
202
|
+
'km_create_booking',
|
|
203
|
+
'Create a REAL confirmed booking on the KM Hub calendar - creates the client if needed and opens a pipeline deal at booked/won. Requires an event_date. Only use when the gig is definitely booked; otherwise use km_create_inquiry.',
|
|
204
|
+
{
|
|
205
|
+
client_name: z.string().describe('Full name of the client.'),
|
|
206
|
+
client_email: z.string().optional(),
|
|
207
|
+
client_phone: z.string().optional(),
|
|
208
|
+
title: z.string().optional().describe('Booking title shown on the calendar.'),
|
|
209
|
+
event_type: z.string().optional(),
|
|
210
|
+
event_date: z.string().describe('YYYY-MM-DD (required for a real booking).'),
|
|
211
|
+
event_start_time: z.string().optional().describe('HH:MM 24h.'),
|
|
212
|
+
event_end_time: z.string().optional().describe('HH:MM 24h.'),
|
|
213
|
+
venue_name: z.string().optional(),
|
|
214
|
+
guest_count: z.number().optional(),
|
|
215
|
+
total_cents: z.number().optional().describe('Total in CENTS.'),
|
|
216
|
+
status: z
|
|
217
|
+
.enum(['pending', 'confirmed'])
|
|
218
|
+
.optional()
|
|
219
|
+
.describe('Default pending (owner reviews); confirmed puts it straight on the calendar.'),
|
|
220
|
+
},
|
|
221
|
+
async (args) => out(await call('POST', '/bookings', args)),
|
|
222
|
+
);
|
|
223
|
+
}
|
package/tools/crm.mjs
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
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
|
+
},
|
|
127
|
+
async ({ deal_id, status, lost_reason, reason }) =>
|
|
128
|
+
out(await call('PATCH', `/deals/${encodeURIComponent(String(deal_id ?? '').trim())}/status`, { status, lost_reason, reason })),
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
server.tool(
|
|
132
|
+
'km_list_tasks',
|
|
133
|
+
'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.',
|
|
134
|
+
{
|
|
135
|
+
limit: z.number().int().min(1).max(100).optional().describe('How many to return. Default 25.'),
|
|
136
|
+
overdue: z.boolean().optional().describe('True = only work that is past its due date and still not done.'),
|
|
137
|
+
status: z
|
|
138
|
+
.enum(['pending', 'in_progress', 'completed', 'cancelled'])
|
|
139
|
+
.optional()
|
|
140
|
+
.describe('Narrow to one state. With overdue this must be pending or in_progress, because finished work cannot be late.'),
|
|
141
|
+
client_id: z.string().optional().describe('Only tasks tied to this client (UUID).'),
|
|
142
|
+
booking_id: z.string().optional().describe('Only tasks tied to this booking (UUID).'),
|
|
143
|
+
},
|
|
144
|
+
async ({ limit, overdue, status, client_id, booking_id }) =>
|
|
145
|
+
out(
|
|
146
|
+
await call(
|
|
147
|
+
'GET',
|
|
148
|
+
`/tasks${qs({
|
|
149
|
+
limit: limit ?? 25,
|
|
150
|
+
overdue: overdue ? 'true' : '',
|
|
151
|
+
status,
|
|
152
|
+
client_id,
|
|
153
|
+
booking_id,
|
|
154
|
+
})}`,
|
|
155
|
+
),
|
|
156
|
+
),
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
server.tool(
|
|
160
|
+
'km_create_task',
|
|
161
|
+
'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.',
|
|
162
|
+
{
|
|
163
|
+
name: z.string().describe('What the task is, in one line. This is what the owner will read on their list.'),
|
|
164
|
+
description: z.string().optional().describe('Any detail that will not fit in the name.'),
|
|
165
|
+
due_date: z.string().optional().describe('YYYY-MM-DD, or a full ISO timestamp when the time of day matters.'),
|
|
166
|
+
client_id: z.string().optional().describe('Tie it to a client (UUID), so it shows on their record.'),
|
|
167
|
+
booking_id: z.string().optional().describe('Tie it to a booking (UUID), so it shows against that job.'),
|
|
168
|
+
},
|
|
169
|
+
async (args) => out(await call('POST', '/tasks', args)),
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
server.tool(
|
|
173
|
+
'km_complete_task',
|
|
174
|
+
'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.',
|
|
175
|
+
{
|
|
176
|
+
task_id: z.string().describe('The task UUID, from km_list_tasks.'),
|
|
177
|
+
},
|
|
178
|
+
async ({ task_id }) =>
|
|
179
|
+
out(await call('POST', `/tasks/${encodeURIComponent(String(task_id ?? '').trim())}/complete`)),
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
server.tool(
|
|
183
|
+
'km_log_touch',
|
|
184
|
+
'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.',
|
|
185
|
+
{
|
|
186
|
+
note: z.string().describe('What happened, in the person\'s own words. Free text, and the more specific the better.'),
|
|
187
|
+
client_id: z.string().optional().describe('The client UUID this happened with. Give this or deal_id.'),
|
|
188
|
+
deal_id: z.string().optional().describe('A deal UUID, when you only know the job. The note goes on that deal\'s contact.'),
|
|
189
|
+
channel: z
|
|
190
|
+
.enum(['call', 'email', 'text', 'in_person', 'social', 'mail', 'other'])
|
|
191
|
+
.optional()
|
|
192
|
+
.describe('How it happened. Default other.'),
|
|
193
|
+
occurred_at: z
|
|
194
|
+
.string()
|
|
195
|
+
.optional()
|
|
196
|
+
.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.'),
|
|
197
|
+
},
|
|
198
|
+
async (args) => out(await call('POST', '/touches', args)),
|
|
199
|
+
);
|
|
200
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool family: knowledge - what the business knows about itself.
|
|
3
|
+
*
|
|
4
|
+
* Every other family answers "what is on the books". This one answers "who are
|
|
5
|
+
* we, what do we sell, at what price, and how do we sound", which is what turns
|
|
6
|
+
* a draft from something a generic assistant would write into something the
|
|
7
|
+
* owner would have written. The intended shape of a session is: pull the person
|
|
8
|
+
* from the CRM tools, pull offers + voice from here, then draft.
|
|
9
|
+
*
|
|
10
|
+
* One tool writes, and what it writes is a PROPOSAL: it lands in KM Hub as a
|
|
11
|
+
* pending fact that the owner confirms before anything uses it. Nothing in this
|
|
12
|
+
* family sends a message, confirms a fact, sets a price, or removes anything.
|
|
13
|
+
*
|
|
14
|
+
* The family contract this file follows is documented in ./README.md.
|
|
15
|
+
*/
|
|
16
|
+
import { z } from 'zod';
|
|
17
|
+
|
|
18
|
+
export const FAMILY = 'knowledge';
|
|
19
|
+
|
|
20
|
+
export const TOOLS = [
|
|
21
|
+
'km_search_knowledge',
|
|
22
|
+
'km_get_business_facts',
|
|
23
|
+
'km_get_brand_voice',
|
|
24
|
+
'km_get_offers_and_pricing',
|
|
25
|
+
'km_search_conversations',
|
|
26
|
+
'km_propose_business_fact',
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
// Any profile whose job is producing words or numbers for a real person needs
|
|
30
|
+
// the grounding: outreach and content write the words, money quotes the prices.
|
|
31
|
+
export const PROFILES = ['outreach', 'content', 'money'];
|
|
32
|
+
|
|
33
|
+
/** Comma-joined query value, or undefined when the caller passed nothing. */
|
|
34
|
+
function csv(list) {
|
|
35
|
+
if (!Array.isArray(list) || list.length === 0) return undefined;
|
|
36
|
+
return list.join(',');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {{ tool: Function }} server guarded registrar (see ./README.md)
|
|
41
|
+
* @param {(method: string, path: string, body?: any) => Promise<{ok:boolean,status:number,data:any}>} call
|
|
42
|
+
* @param {{ out: Function, qs: Function }} helpers shared response helpers from ../tools.mjs
|
|
43
|
+
*/
|
|
44
|
+
export function register(server, call, { out, qs }) {
|
|
45
|
+
server.tool(
|
|
46
|
+
'km_search_knowledge',
|
|
47
|
+
'Search everything written down in this KM Hub workspace by meaning rather than by exact words: notes on a client, what someone asked for last time, access and setup details, special requirements, the stray line somebody typed into a booking. Reach for it when you need something that is not a tidy field on a record, for example before writing to a lead, or when you are asked what was agreed with somebody. It searches only this workspace and only what has been indexed, so an empty result means nobody wrote it down here, NOT that it is untrue: ask the person instead of filling the gap. It does not search the public internet, it does not read a live inbox, and it changes nothing.',
|
|
48
|
+
{
|
|
49
|
+
q: z.string().describe('What you are looking for, in plain words. A question works better than a keyword.'),
|
|
50
|
+
entity_types: z
|
|
51
|
+
.array(z.enum(['clients', 'bookings', 'invoices', 'contacts', 'projects']))
|
|
52
|
+
.optional()
|
|
53
|
+
.describe('Narrow the search when you already know where the answer lives. Leave it out to search everything.'),
|
|
54
|
+
limit: z.number().int().min(1).max(20).optional().describe('How many passages to return. Default 5.'),
|
|
55
|
+
},
|
|
56
|
+
async ({ q, entity_types, limit }) =>
|
|
57
|
+
out(await call('GET', `/knowledge/search${qs({ q, entity_types: csv(entity_types), limit })}`)),
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
server.tool(
|
|
61
|
+
'km_get_business_facts',
|
|
62
|
+
'What this business has told KM Hub about itself: the facts its owner has CONFIRMED, the standing facts they asked to be remembered permanently, and the knowledge distilled from documents they uploaded such as past proposals, pricing sheets and contracts. Read it before you write anything in the business name, so you work from what is actually true here instead of from a generic idea of the trade. Everything it returns is owner-approved and safe to rely on. Facts that are still waiting for the owner are counted but deliberately not shown, because nobody has confirmed them yet - if that count is above zero it is worth telling the person they have some waiting. Read only.',
|
|
63
|
+
{
|
|
64
|
+
kind: z
|
|
65
|
+
.enum(['business', 'service', 'price', 'voice', 'vertical_craft'])
|
|
66
|
+
.optional()
|
|
67
|
+
.describe('Narrow to one sort of fact. Leave it out for all of them.'),
|
|
68
|
+
topic: z
|
|
69
|
+
.string()
|
|
70
|
+
.optional()
|
|
71
|
+
.describe('What the task is about. Used only to pick the most relevant uploaded documents, for example "wedding proposal" or "cancellation policy".'),
|
|
72
|
+
limit: z.number().int().min(1).max(100).optional().describe('How many facts to return. Default 40.'),
|
|
73
|
+
},
|
|
74
|
+
async ({ kind, topic, limit }) => out(await call('GET', `/knowledge/facts${qs({ kind, topic, limit })}`)),
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
server.tool(
|
|
78
|
+
'km_get_brand_voice',
|
|
79
|
+
'How this business sounds: the owner own words about their values and tone, the writing sample they saved, the phrases they actually use, how they sign off, their positioning and brand messages, and the house rules any outgoing message has to obey. Read it before drafting an email, a message, a post or a proposal so the draft sounds like them rather than like an assistant. Treat all of it as STYLE: match the rhythm, the vocabulary and the level of formality, never paste a saved sample sentence into a real message, and never treat a phrase from it as a fact about a client. If the workspace has no voice recorded yet the tool says so plainly, and the right move then is to write simply and ask the owner rather than invent a house style. Read only: drafting still goes through km_create_outreach_draft, which queues the message for a human and never sends.',
|
|
80
|
+
{},
|
|
81
|
+
async () => out(await call('GET', '/knowledge/voice')),
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
server.tool(
|
|
85
|
+
'km_get_offers_and_pricing',
|
|
86
|
+
'Everything this business sells and every price it has actually published: the service price list, the packages and their add-ons, the packages used in proposals, the bookable offerings with their deposit and contract rules, and the price ladder the owner set. This is the ONLY place a price may come from. Quote a figure from here exactly, in the currency it returns, and respect the units it labels for you, because some fields are in cents and some are in whole currency units. Never average two prices, never round one up, never blend them into a number of your own, and if there is no published price for what is being asked, say the owner will confirm it rather than guessing. Call this before any message, quote or proposal that mentions money. It reads only: it cannot set a price and it cannot commit the business to one.',
|
|
87
|
+
{
|
|
88
|
+
limit: z.number().int().min(1).max(100).optional().describe('How many rows per list. Default 50.'),
|
|
89
|
+
},
|
|
90
|
+
async ({ limit }) => out(await call('GET', `/knowledge/offers${qs({ limit })}`)),
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
server.tool(
|
|
94
|
+
'km_search_conversations',
|
|
95
|
+
'Search the email history recorded in this workspace: what a person actually wrote, what was already promised them, what was last sent. Reach for it before replying to somebody or drafting a follow up, so you pick up the thread instead of starting the conversation again and asking for something they already told you. Search by words, or read one person history with their contact id from km_find_contact, or both together. It returns short snippets rather than whole emails, so open the thread in KM Hub when you need the full text. It only reads mail already recorded in KM Hub, and it never sends, replies to, or changes anything.',
|
|
96
|
+
{
|
|
97
|
+
q: z.string().optional().describe('Words to look for in the subject or the body. Leave it out when you are reading one person history.'),
|
|
98
|
+
contact_id: z.string().optional().describe('UUID of the client or contact whose mail you want (see km_find_contact).'),
|
|
99
|
+
direction: z
|
|
100
|
+
.enum(['inbound', 'outbound'])
|
|
101
|
+
.optional()
|
|
102
|
+
.describe('inbound is what they sent us, outbound is what we sent them. Leave it out for both.'),
|
|
103
|
+
limit: z.number().int().min(1).max(50).optional().describe('How many messages to return. Default 20.'),
|
|
104
|
+
},
|
|
105
|
+
async ({ q, contact_id, direction, limit }) =>
|
|
106
|
+
out(await call('GET', `/knowledge/conversations${qs({ q, contact_id, direction, limit })}`)),
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
server.tool(
|
|
110
|
+
'km_propose_business_fact',
|
|
111
|
+
'Write down something durable this business has learned about itself, for example that they no longer travel more than two hours, or that their busy season starts in March, or that they always need a stage of a certain size. It is saved as a PROPOSAL and not as truth: it waits in KM Hub under Business Knowledge until the owner confirms it, and no draft, quote or answer will use it before then. Use it only for something that will still be true next month. A detail about ONE client or ONE booking is not a business fact and belongs on that record instead. Always say in source_note where the fact came from, in one line, because that is what the owner reads when deciding whether to accept it. After calling this, tell the person plainly that it is waiting for their approval. This tool cannot confirm a fact, cannot edit or replace an existing one, and cannot remove one.',
|
|
112
|
+
{
|
|
113
|
+
text: z.string().describe('The fact itself, in one plain sentence, written the way the owner would say it.'),
|
|
114
|
+
source_note: z
|
|
115
|
+
.string()
|
|
116
|
+
.describe('Where this came from, in one line, for example "the owner said so in this conversation on 4 March" or "from the venue email they forwarded".'),
|
|
117
|
+
kind: z
|
|
118
|
+
.enum(['business', 'service', 'price', 'voice', 'vertical_craft'])
|
|
119
|
+
.optional()
|
|
120
|
+
.describe('What sort of fact it is. business is the default and covers how they operate. Use price only for a real published price, service for something they offer, voice for how they sound.'),
|
|
121
|
+
},
|
|
122
|
+
async (args) => out(await call('POST', '/knowledge/facts', args)),
|
|
123
|
+
);
|
|
124
|
+
}
|