@setluca/mcp 0.2.1 → 0.3.1
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/CHANGELOG.md +331 -0
- package/LICENSE +21 -0
- package/README.md +19 -288
- package/dist/annotations.js +33 -18
- package/dist/client-address.js +64 -0
- package/dist/completions.js +24 -27
- package/dist/config.js +89 -38
- package/dist/errors.js +94 -13
- package/dist/fields.js +68 -0
- package/dist/fixed-window.js +46 -0
- package/dist/generated/openapi-inputs.js +216 -5820
- package/dist/generated/openapi-outputs.js +19014 -14001
- package/dist/generated/route-catalog.js +80 -0
- package/dist/http.js +107 -44
- package/dist/index.js +4 -1
- package/dist/oauth-challenge.js +74 -0
- package/dist/oauth-resolver.js +58 -61
- package/dist/observability.js +30 -15
- package/dist/openapi-schema.js +175 -0
- package/dist/operations/catalog.js +17 -0
- package/dist/operations/groups/analytics.js +20 -10
- package/dist/operations/groups/bookings.js +43 -15
- package/dist/operations/groups/broadcasts.js +29 -58
- package/dist/operations/groups/cadences.js +3 -1
- package/dist/operations/groups/callEvents.js +19 -72
- package/dist/operations/groups/campaigns.js +28 -32
- package/dist/operations/groups/channels.js +23 -0
- package/dist/operations/groups/conversations.js +14 -25
- package/dist/operations/groups/insights.js +4 -0
- package/dist/operations/groups/integrations.js +15 -17
- package/dist/operations/groups/knowledge.js +5 -1
- package/dist/operations/groups/leads.js +22 -23
- package/dist/operations/groups/learning.js +13 -1
- package/dist/operations/groups/memory.js +4 -6
- package/dist/operations/groups/reports.js +1 -1
- package/dist/operations/groups/reviewQueue.js +17 -14
- package/dist/operations/groups/safety.js +1 -1
- package/dist/operations/groups/voice.js +0 -1
- package/dist/operations/groups/webhooks.js +15 -3
- package/dist/operations/manifest.js +7 -6
- package/dist/operations/registry.js +102 -108
- package/dist/operations.js +4 -3
- package/dist/optional-field.js +15 -3
- package/dist/page-contract.js +74 -0
- package/dist/pagination.js +147 -67
- package/dist/prompts.js +122 -97
- package/dist/rate-limit.js +40 -0
- package/dist/remote.js +145 -65
- package/dist/resilience.js +107 -52
- package/dist/resolvers.js +125 -0
- package/dist/scopes.js +70 -17
- package/dist/serialization.js +32 -22
- package/dist/server.js +148 -132
- package/dist/standard-schema.js +215 -0
- package/dist/task-tools/analytics.js +145 -86
- package/dist/task-tools/bookings.js +168 -81
- package/dist/task-tools/cadences.js +30 -45
- package/dist/task-tools/calls.js +131 -124
- package/dist/task-tools/inbox.js +37 -47
- package/dist/task-tools/leads.js +49 -77
- package/dist/task-tools/runtime.js +75 -15
- package/dist/token-verification.js +47 -0
- package/dist/tool-cache.js +3 -2
- package/dist/tool.js +151 -96
- package/dist/version.js +1 -1
- package/package.json +37 -16
- package/dist/openapi-zod.js +0 -335
- package/dist/page-shape.js +0 -62
- package/dist/tsconfig.tsbuildinfo +0 -1
|
@@ -1,59 +1,15 @@
|
|
|
1
|
-
import
|
|
2
|
-
import * as
|
|
1
|
+
import * as Arr from "effect/Array";
|
|
2
|
+
import * as Effect from "effect/Effect";
|
|
3
|
+
import * as Option from "effect/Option";
|
|
4
|
+
import * as R from "effect/Record";
|
|
5
|
+
import * as Result from "effect/Result";
|
|
6
|
+
import * as Schema from "effect/Schema";
|
|
3
7
|
import { formatError } from "../errors.js";
|
|
4
|
-
import { WORKSPACE_INPUT_FIELDS } from "../
|
|
8
|
+
import { NonEmptyString, WORKSPACE_INPUT_FIELDS } from "../fields.js";
|
|
5
9
|
import { optionalField } from "../optional-field.js";
|
|
6
|
-
import { listingIds, numberInput,
|
|
10
|
+
import { failureAsField, listingIds, numberInput, operationResult, resultOrError, sectionResult, taskTool, } from "./runtime.js";
|
|
7
11
|
const DEFAULT_CAMPAIGN_LIMIT = 5;
|
|
8
12
|
const CAMPAIGN_BROADCAST_LIMIT = 10;
|
|
9
|
-
const getAnalytics = {
|
|
10
|
-
name: "luca_analytics_rollup",
|
|
11
|
-
title: "Get campaign and broadcast analytics",
|
|
12
|
-
description: "Roll up performance across the coach's campaigns (per-campaign analytics for up to 5 recent campaigns) and recent broadcasts. Example: call with {} for the default rollup, or { campaignLimit: 3 }.",
|
|
13
|
-
inputSchema: {
|
|
14
|
-
...WORKSPACE_INPUT_FIELDS,
|
|
15
|
-
campaignLimit: z
|
|
16
|
-
.number()
|
|
17
|
-
.int()
|
|
18
|
-
.min(1)
|
|
19
|
-
.max(5)
|
|
20
|
-
.optional()
|
|
21
|
-
.describe("How many recent campaigns to include (default 5)."),
|
|
22
|
-
},
|
|
23
|
-
readOnly: true,
|
|
24
|
-
untrustedContent: false,
|
|
25
|
-
composes: ["campaigns.list", "campaigns.analytics", "broadcasts.list"],
|
|
26
|
-
run: (api, operationById, input) => Effect.gen(function* () {
|
|
27
|
-
const campaignLimit = numberInput(input.campaignLimit, DEFAULT_CAMPAIGN_LIMIT);
|
|
28
|
-
const campaignPage = yield* api.request(taskRequest(operationById("campaigns.list"), input, {
|
|
29
|
-
query: { limit: campaignLimit },
|
|
30
|
-
}));
|
|
31
|
-
// broadcasts.list doesn't depend on the per-campaign analytics loop, so
|
|
32
|
-
// it runs alongside it instead of after it. Effect.all defaults to
|
|
33
|
-
// concurrency 1 (sequential), so without `concurrency: "unbounded"` here
|
|
34
|
-
// the broadcasts call would still wait on the whole loop below.
|
|
35
|
-
const [perCampaign, broadcasts] = yield* Effect.all([
|
|
36
|
-
Effect.forEach(listingIds(campaignPage, campaignLimit), (id) => api
|
|
37
|
-
.request(taskRequest(operationById("campaigns.analytics"), input, {
|
|
38
|
-
pathParams: { id },
|
|
39
|
-
}))
|
|
40
|
-
.pipe(Effect.catchAll((error) =>
|
|
41
|
-
// Structured formatter (see draft-belt note above): preserves
|
|
42
|
-
// the failing campaign's status/requestId instead of "[object …]".
|
|
43
|
-
Effect.succeed({ analyticsError: formatError(error) })), Effect.map((analytics) => [
|
|
44
|
-
id,
|
|
45
|
-
analytics,
|
|
46
|
-
])),
|
|
47
|
-
// Effect.forEach also defaults to concurrency 1. campaignLimit caps
|
|
48
|
-
// this at 5 independent per-campaign lookups, so unbounded is fine.
|
|
49
|
-
{ concurrency: "unbounded" }),
|
|
50
|
-
api.request(taskRequest(operationById("broadcasts.list"), input, {
|
|
51
|
-
query: { limit: CAMPAIGN_BROADCAST_LIMIT },
|
|
52
|
-
})),
|
|
53
|
-
], { concurrency: "unbounded" });
|
|
54
|
-
return { campaigns: Record.fromEntries(perCampaign), broadcasts };
|
|
55
|
-
}),
|
|
56
|
-
};
|
|
57
13
|
const ANALYTICS_PERIOD_VALUES = [
|
|
58
14
|
"this_week",
|
|
59
15
|
"this_month",
|
|
@@ -61,11 +17,12 @@ const ANALYTICS_PERIOD_VALUES = [
|
|
|
61
17
|
"last_month",
|
|
62
18
|
"last_30_days",
|
|
63
19
|
];
|
|
20
|
+
const decodeAnalyticsPeriod = Schema.decodeUnknownOption(Schema.Literals(ANALYTICS_PERIOD_VALUES));
|
|
64
21
|
/**
|
|
65
22
|
* Every analytics read either task tool below can compose. magic_monday and
|
|
66
|
-
* deep_dive each build their own list from these entries
|
|
67
|
-
*
|
|
68
|
-
*
|
|
23
|
+
* deep_dive each build their own list from these entries, so the two tools
|
|
24
|
+
* cannot drift apart on order or on which reads are period-scoped. A read whose
|
|
25
|
+
* id is missing from the tool's `composes` does not compile.
|
|
69
26
|
*/
|
|
70
27
|
const ANALYTICS_READS = {
|
|
71
28
|
funnel: { key: "funnel", id: "analytics.funnel", periodScoped: true },
|
|
@@ -110,57 +67,159 @@ const DEEP_DIVE_READS = [
|
|
|
110
67
|
ANALYTICS_READS.ghostedLeads,
|
|
111
68
|
ANALYTICS_READS.trustScore,
|
|
112
69
|
];
|
|
70
|
+
/** A section the report left out because its read failed. */
|
|
71
|
+
const FailedSection = Schema.Struct({
|
|
72
|
+
section: Schema.String,
|
|
73
|
+
error: Schema.String,
|
|
74
|
+
});
|
|
75
|
+
/**
|
|
76
|
+
* Each read's response under the key it lands at, present when the read
|
|
77
|
+
* succeeded, plus `failedSections` naming each read that did not.
|
|
78
|
+
*/
|
|
79
|
+
function analyticsOutput(reads) {
|
|
80
|
+
return Schema.Struct({
|
|
81
|
+
...R.fromEntries(reads.map((read) => [
|
|
82
|
+
read.key,
|
|
83
|
+
Schema.optionalKey(operationResult(read.id)),
|
|
84
|
+
])),
|
|
85
|
+
failedSections: Schema.optionalKey(Schema.Array(FailedSection).annotate({
|
|
86
|
+
description: "Sections missing from this report because their read failed. The report is partial whenever this is present.",
|
|
87
|
+
})),
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Fold the outcome of each section into one report. A failed section is left
|
|
92
|
+
* out and named in `failedSections`, so the numbers that did load still reach
|
|
93
|
+
* the coach. The call fails only when every section does, since an empty
|
|
94
|
+
* report answers nothing.
|
|
95
|
+
*/
|
|
96
|
+
function collectSections(outcomes) {
|
|
97
|
+
const sections = outcomes.flatMap(({ key, result }) => Result.isSuccess(result) ? [[key, result.success]] : []);
|
|
98
|
+
const failures = outcomes.flatMap(({ key, result }) => Result.isFailure(result) ? [{ key, error: result.failure }] : []);
|
|
99
|
+
return Arr.match(failures, {
|
|
100
|
+
onEmpty: () => Effect.succeed(R.fromEntries(sections)),
|
|
101
|
+
onNonEmpty: (failed) => Arr.isReadonlyArrayEmpty(sections)
|
|
102
|
+
? Effect.fail(Arr.headNonEmpty(failed).error)
|
|
103
|
+
: Effect.succeed({
|
|
104
|
+
...R.fromEntries(sections),
|
|
105
|
+
failedSections: failed.map(({ key, error }) => ({
|
|
106
|
+
section: key,
|
|
107
|
+
error: formatError(error),
|
|
108
|
+
})),
|
|
109
|
+
}),
|
|
110
|
+
});
|
|
111
|
+
}
|
|
113
112
|
/**
|
|
114
113
|
* Run a list of analytics reads in parallel and return them keyed by `key`.
|
|
115
114
|
* Effect.all defaults to concurrency 1 (sequential); these reads don't depend
|
|
116
115
|
* on each other, so serializing them would only add latency, not correctness.
|
|
116
|
+
*
|
|
117
|
+
* One failed read leaves its section out and names it in `failedSections`; see
|
|
118
|
+
* {@link collectSections}. A refused token or missing scope fails the whole
|
|
119
|
+
* report instead, see {@link sectionResult}.
|
|
117
120
|
*/
|
|
118
|
-
function analyticsFanOut(
|
|
119
|
-
return Effect.
|
|
120
|
-
.request(taskRequest(operationById(read.id), input, {
|
|
121
|
+
function analyticsFanOut(call, period, reads) {
|
|
122
|
+
return Effect.forEach(reads, (read) => sectionResult(call(read.id, {
|
|
121
123
|
...optionalField("query", read.periodScoped ? { period } : undefined),
|
|
122
|
-
}))
|
|
123
|
-
.pipe(Effect.map((value) => [read.key, value]))), { concurrency: "unbounded" }).pipe(Effect.map((entries) => Record.fromEntries(entries)));
|
|
124
|
+
})).pipe(Effect.map((result) => ({ key: read.key, result }))), { concurrency: "unbounded" }).pipe(Effect.flatMap(collectSections));
|
|
124
125
|
}
|
|
125
|
-
|
|
126
|
+
/**
|
|
127
|
+
* Analytics for each campaign on a listing page, keyed by campaign id. One
|
|
128
|
+
* campaign's failure is reported in its slot rather than dropping the rollup
|
|
129
|
+
* for every other campaign, unless it is an authorization failure, see
|
|
130
|
+
* {@link failureAsField}.
|
|
131
|
+
*/
|
|
132
|
+
function campaignAnalytics(call, page, limit) {
|
|
133
|
+
return Effect.forEach(listingIds(page, limit), (id) => failureAsField("analyticsError", call("campaigns.analytics", { pathParams: { id } })).pipe(Effect.map((analytics) => [id, analytics])),
|
|
134
|
+
// Effect.forEach also defaults to concurrency 1. The limit caps this at 5
|
|
135
|
+
// independent per-campaign lookups, so unbounded is fine.
|
|
136
|
+
{ concurrency: "unbounded" }).pipe(Effect.map(R.fromEntries));
|
|
137
|
+
}
|
|
138
|
+
const getAnalytics = taskTool({
|
|
139
|
+
name: "luca_analytics_rollup",
|
|
140
|
+
title: "Get campaign and broadcast analytics",
|
|
141
|
+
description: "Roll up performance across the coach's campaigns (per-campaign analytics for up to 5 recent campaigns) and recent broadcasts. When one half fails to load, the other still comes back and failedSections names the one that is missing. Example: call with {} for the default rollup, or { campaignLimit: 3 }.",
|
|
142
|
+
inputSchema: {
|
|
143
|
+
...WORKSPACE_INPUT_FIELDS,
|
|
144
|
+
campaignLimit: Schema.optionalKey(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 5 })).annotate({
|
|
145
|
+
description: "How many recent campaigns to include (default 5).",
|
|
146
|
+
})),
|
|
147
|
+
},
|
|
148
|
+
outputSchema: Schema.Struct({
|
|
149
|
+
campaigns: Schema.optionalKey(Schema.Record(Schema.String, resultOrError("campaigns.analytics", "analyticsError"))),
|
|
150
|
+
broadcasts: Schema.optionalKey(operationResult("broadcasts.list")),
|
|
151
|
+
failedSections: Schema.optionalKey(Schema.Array(FailedSection).annotate({
|
|
152
|
+
description: "Sections missing from this rollup because their read failed (campaigns, broadcasts). The rollup is partial whenever this is present.",
|
|
153
|
+
})),
|
|
154
|
+
}),
|
|
155
|
+
composes: ["campaigns.list", "campaigns.analytics", "broadcasts.list"],
|
|
156
|
+
run: (call, input) => Effect.gen(function* () {
|
|
157
|
+
const campaignLimit = numberInput(input.campaignLimit, DEFAULT_CAMPAIGN_LIMIT);
|
|
158
|
+
const campaignPage = yield* sectionResult(call("campaigns.list", { query: { limit: campaignLimit } }));
|
|
159
|
+
// broadcasts.list doesn't depend on the per-campaign analytics loop, so
|
|
160
|
+
// it runs alongside it instead of after it. Effect.all defaults to
|
|
161
|
+
// concurrency 1 (sequential), so without `concurrency: "unbounded"` here
|
|
162
|
+
// the broadcasts call would still wait on the whole loop below.
|
|
163
|
+
const [campaigns, broadcasts] = yield* Effect.all([
|
|
164
|
+
Result.match(campaignPage, {
|
|
165
|
+
onFailure: (error) => Effect.succeed(Result.fail(error)),
|
|
166
|
+
onSuccess: (page) => sectionResult(campaignAnalytics(call, page, campaignLimit)),
|
|
167
|
+
}),
|
|
168
|
+
sectionResult(call("broadcasts.list", {
|
|
169
|
+
query: { limit: CAMPAIGN_BROADCAST_LIMIT },
|
|
170
|
+
})),
|
|
171
|
+
], { concurrency: "unbounded" });
|
|
172
|
+
return yield* collectSections([
|
|
173
|
+
{ key: "campaigns", result: campaigns },
|
|
174
|
+
{ key: "broadcasts", result: broadcasts },
|
|
175
|
+
]);
|
|
176
|
+
}),
|
|
177
|
+
});
|
|
178
|
+
const magicMonday = taskTool({
|
|
126
179
|
name: "luca_analytics_magic_monday",
|
|
127
180
|
title: "Get the Magic Monday report",
|
|
128
181
|
description: "Return the coach's weekly business intelligence report: pipeline funnel, revenue and ROI, response-speed impact, a point-in-time revenue forecast, and call intelligence (show/close rates), all for the current week. Composes five analytics reads into a single call. Example: call with {} on Monday morning for the week's report.",
|
|
129
182
|
inputSchema: {
|
|
130
183
|
...WORKSPACE_INPUT_FIELDS,
|
|
131
184
|
},
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
185
|
+
outputSchema: analyticsOutput(MAGIC_MONDAY_READS),
|
|
186
|
+
composes: [
|
|
187
|
+
"analytics.funnel",
|
|
188
|
+
"analytics.revenue",
|
|
189
|
+
"analytics.speedImpact",
|
|
190
|
+
"analytics.forecast",
|
|
191
|
+
"analytics.callIntelligence",
|
|
192
|
+
],
|
|
193
|
+
run: (call) => analyticsFanOut(call, "this_week", MAGIC_MONDAY_READS),
|
|
194
|
+
});
|
|
195
|
+
const analyticsDeepDive = taskTool({
|
|
138
196
|
name: "luca_analytics_deep_dive",
|
|
139
197
|
title: "Run a deep-dive analytics investigation",
|
|
140
198
|
description: 'Answer a specific business question by composing all seven analytics reads in one call: pipeline funnel, revenue and ROI, response-speed impact, a point-in-time revenue forecast, call intelligence (show/close rates), ghosted-lead recovery, and coach trust/auto-send status. Broader than luca_analytics_magic_monday (which is the fixed weekly report); use this when the coach asks a specific question that needs cross-referencing several of these numbers together. Example: call with {question: "why did close rate drop this month?", period: "this_month"}.',
|
|
141
199
|
inputSchema: {
|
|
142
200
|
...WORKSPACE_INPUT_FIELDS,
|
|
143
|
-
question:
|
|
144
|
-
.
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
.enum(ANALYTICS_PERIOD_VALUES)
|
|
150
|
-
.optional()
|
|
151
|
-
.describe("Period for the period-scoped reads (funnel, revenue, speedImpact, callIntelligence, ghostedLeads, trustScore). Forecast ignores this — it is always point-in-time. Defaults to this_month."),
|
|
201
|
+
question: Schema.optionalKey(NonEmptyString.check(Schema.isMaxLength(500)).annotate({
|
|
202
|
+
description: "Optional. The coach's question behind this investigation, e.g. 'why did close rate drop this month?'. Not sent to any API; it only records what the coach asked.",
|
|
203
|
+
})),
|
|
204
|
+
period: Schema.optionalKey(Schema.Literals(ANALYTICS_PERIOD_VALUES).annotate({
|
|
205
|
+
description: "Period for the period-scoped reads (funnel, revenue, speedImpact, callIntelligence, ghostedLeads, trustScore). Forecast ignores this — it is always point-in-time. Defaults to this_month.",
|
|
206
|
+
})),
|
|
152
207
|
},
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
208
|
+
outputSchema: analyticsOutput(DEEP_DIVE_READS),
|
|
209
|
+
composes: [
|
|
210
|
+
"analytics.funnel",
|
|
211
|
+
"analytics.revenue",
|
|
212
|
+
"analytics.speedImpact",
|
|
213
|
+
"analytics.forecast",
|
|
214
|
+
"analytics.callIntelligence",
|
|
215
|
+
"analytics.ghostedLeads",
|
|
216
|
+
"analytics.trustScore",
|
|
217
|
+
],
|
|
218
|
+
run: (call, input) => {
|
|
219
|
+
const period = Option.getOrElse(decodeAnalyticsPeriod(input.period), () => "this_month");
|
|
220
|
+
return analyticsFanOut(call, period, DEEP_DIVE_READS);
|
|
162
221
|
},
|
|
163
|
-
};
|
|
222
|
+
});
|
|
164
223
|
export const analyticsTaskTools = [
|
|
165
224
|
getAnalytics,
|
|
166
225
|
magicMonday,
|
|
@@ -1,44 +1,100 @@
|
|
|
1
|
-
import
|
|
2
|
-
import * as
|
|
3
|
-
import
|
|
1
|
+
import * as Effect from "effect/Effect";
|
|
2
|
+
import * as Option from "effect/Option";
|
|
3
|
+
import * as Schema from "effect/Schema";
|
|
4
|
+
import { IsoDateTime, NonEmptyString, WORKSPACE_INPUT_FIELDS, } from "../fields.js";
|
|
4
5
|
import { optionalField } from "../optional-field.js";
|
|
5
|
-
import {
|
|
6
|
+
import { failureAsField, idempotencyKeyInput, isPartialFailure, numberInput, operationResult, resultOrError, stringInput, taskTool, } from "./runtime.js";
|
|
7
|
+
/** The providers the API accepts on an availability read and on a booking. */
|
|
8
|
+
const BOOKING_PROVIDERS = [
|
|
9
|
+
"manual",
|
|
10
|
+
"calendly",
|
|
11
|
+
"cal_com",
|
|
12
|
+
"google_calendar",
|
|
13
|
+
"iclosed",
|
|
14
|
+
"gohighlevel",
|
|
15
|
+
];
|
|
16
|
+
/**
|
|
17
|
+
* Whether the caller is retrying under a key. A retry may follow a booking that
|
|
18
|
+
* landed while its response was lost, so the slot can be taken by that very
|
|
19
|
+
* booking. Checking availability first would answer slot_not_available and
|
|
20
|
+
* never reach the API's replay of the first result.
|
|
21
|
+
*/
|
|
22
|
+
function isRetry(input) {
|
|
23
|
+
return input.idempotencyKey !== undefined;
|
|
24
|
+
}
|
|
6
25
|
/** How far past the requested slot to look when offering alternatives. */
|
|
7
26
|
const AVAILABILITY_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
27
|
+
const AvailabilityPage = Schema.Struct({
|
|
28
|
+
source: Schema.String,
|
|
29
|
+
slots: Schema.Array(Schema.Struct({ startsAt: Schema.String })),
|
|
30
|
+
});
|
|
31
|
+
const decodeAvailabilityPage = Schema.decodeUnknownOption(AvailabilityPage);
|
|
32
|
+
const ManagedAvailabilityPage = Schema.Struct({
|
|
33
|
+
slots: Schema.Array(Schema.Struct({ startsAt: Schema.String })),
|
|
34
|
+
});
|
|
35
|
+
const decodeManagedAvailabilityPage = Schema.decodeUnknownOption(ManagedAvailabilityPage);
|
|
36
|
+
const BookingBookingType = Schema.Struct({
|
|
37
|
+
booking: Schema.Struct({
|
|
38
|
+
bookingTypeId: Schema.optionalKey(Schema.NullOr(Schema.String)),
|
|
39
|
+
}),
|
|
40
|
+
});
|
|
41
|
+
const decodeBookingBookingType = Schema.decodeUnknownOption(BookingBookingType);
|
|
8
42
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* `Option.none()` means "we could not tell", which is different from "nothing
|
|
12
|
-
* is open". The availability route answers with `source: "fallback"` or
|
|
43
|
+
* Open slots from the provider route. It answers with `source: "fallback"` or
|
|
13
44
|
* `"provider_error"` when it could not reach the provider, and it defaults to
|
|
14
45
|
* Calendly when the caller names no provider, so a coach on Cal.com asking
|
|
15
46
|
* without one gets a confident-looking answer about the wrong calendar. Only a
|
|
16
|
-
* `live_provider` read
|
|
47
|
+
* `live_provider` read counts.
|
|
48
|
+
*/
|
|
49
|
+
const providerSlots = (call, input, window) => call("bookings.availability", {
|
|
50
|
+
query: { ...window, ...optionalField("provider", input.provider) },
|
|
51
|
+
}).pipe(Effect.map((page) => decodeAvailabilityPage(page).pipe(Option.filter((parsed) => parsed.source === "live_provider"), Option.map((parsed) => parsed.slots))));
|
|
52
|
+
/**
|
|
53
|
+
* Open slots for one booking type, from Luca's own scheduler. The provider
|
|
54
|
+
* route knows nothing of booking types, so it would judge the slot against
|
|
55
|
+
* the wrong calendar. Times are compared as instants, so the display timezone
|
|
56
|
+
* does not matter.
|
|
57
|
+
*/
|
|
58
|
+
const bookingTypeSlots = (call, bookingTypeId, window) => call("bookings.managedAvailability", {
|
|
59
|
+
query: { ...window, bookingTypeId, timezone: "UTC" },
|
|
60
|
+
}).pipe(Effect.map((page) => decodeManagedAvailabilityPage(page).pipe(Option.map((parsed) => parsed.slots))));
|
|
61
|
+
/**
|
|
62
|
+
* The booking type a booking was made under, read from the booking itself.
|
|
63
|
+
*
|
|
64
|
+
* `Option.none()` means the read failed, and `Option.some(null)` is a booking
|
|
65
|
+
* on no booking type. Both are judged against the provider calendar, as before
|
|
66
|
+
* this read existed. A refused token still ends the call, see
|
|
67
|
+
* isPartialFailure.
|
|
68
|
+
*/
|
|
69
|
+
const bookingTypeOf = (call, bookingId) => call("bookings.get", { pathParams: { id: bookingId } }).pipe(Effect.map((page) => decodeBookingBookingType(page).pipe(Option.map((parsed) => parsed.booking.bookingTypeId ?? null))), Effect.catchIf(isPartialFailure, () => Effect.succeedNone));
|
|
70
|
+
/**
|
|
71
|
+
* The slots `readSlots` reports open in the window starting at `slotAt`.
|
|
72
|
+
*
|
|
73
|
+
* `Option.none()` means "we could not tell", which is different from "nothing
|
|
74
|
+
* is open", and never blocks a booking. A call that carries an idempotency key
|
|
75
|
+
* skips the read, see {@link isRetry}.
|
|
17
76
|
*/
|
|
18
|
-
const openSlotsAt = (
|
|
77
|
+
const openSlotsAt = (input, slotAt, readSlots) => Effect.gen(function* () {
|
|
78
|
+
// A keyed call is a retry, and the API replays the first answer for it.
|
|
79
|
+
if (isRetry(input)) {
|
|
80
|
+
return Option.none();
|
|
81
|
+
}
|
|
82
|
+
// oxlint-disable-next-line effect/use-clock-service -- parse date value from stored data
|
|
19
83
|
const start = new Date(slotAt);
|
|
20
84
|
if (Number.isNaN(start.getTime())) {
|
|
21
85
|
return Option.none();
|
|
22
86
|
}
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
})),
|
|
87
|
+
const window = {
|
|
88
|
+
start: start.toISOString(),
|
|
89
|
+
// oxlint-disable-next-line effect/use-clock-service -- parse date value from stored data
|
|
90
|
+
end: new Date(start.getTime() + AVAILABILITY_WINDOW_MS).toISOString(),
|
|
91
|
+
};
|
|
92
|
+
const slots = yield* readSlots(window).pipe(
|
|
30
93
|
// A failed availability read must not block the booking. The agent is no
|
|
31
|
-
// worse off than before this check existed.
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
return Option.none();
|
|
36
|
-
}
|
|
37
|
-
return Option.some(parsed.data.slots.map((slot) => slot.startsAt));
|
|
38
|
-
});
|
|
39
|
-
const AvailabilityPageSchema = z.object({
|
|
40
|
-
source: z.string(),
|
|
41
|
-
slots: z.array(z.object({ startsAt: z.string() })),
|
|
94
|
+
// worse off than before this check existed. A refused token still ends
|
|
95
|
+
// the call, see isPartialFailure.
|
|
96
|
+
Effect.catchIf(isPartialFailure, () => Effect.succeedNone));
|
|
97
|
+
return Option.map(slots, (open) => open.map((slot) => slot.startsAt));
|
|
42
98
|
});
|
|
43
99
|
/**
|
|
44
100
|
* Whether two ISO-8601 instants name the same moment, whatever their offset.
|
|
@@ -47,31 +103,52 @@ const AvailabilityPageSchema = z.object({
|
|
|
47
103
|
* slot on either side answers false without a guard of its own.
|
|
48
104
|
*/
|
|
49
105
|
function sameInstant(left, right) {
|
|
106
|
+
// oxlint-disable-next-line effect/use-clock-service -- parse date value from stored data
|
|
50
107
|
return new Date(left).getTime() === new Date(right).getTime();
|
|
51
108
|
}
|
|
52
|
-
|
|
109
|
+
/** What a booking tool returns instead of writing when the slot is taken. */
|
|
110
|
+
function slotNotAvailable(key) {
|
|
111
|
+
return Schema.Struct({
|
|
112
|
+
[key]: Schema.Literal(false),
|
|
113
|
+
reason: Schema.Literal("slot_not_available"),
|
|
114
|
+
requestedSlotAt: Schema.String,
|
|
115
|
+
openSlots: Schema.Array(Schema.String),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
const bookCall = taskTool({
|
|
53
119
|
name: "luca_book_call",
|
|
54
120
|
title: "Book a call with a lead",
|
|
55
|
-
description: 'Book a lead into a slot the provider confirms is open. Checks availability first and returns the open slots instead of booking when the requested one is taken. Example: call with { leadId, slotAt: "2026-07-14T15:00:00Z" } after the lead agreed to a time.',
|
|
121
|
+
description: 'Book a lead into a slot the provider confirms is open. Checks availability first, against the schedule of the booking type when bookingTypeId is given, and returns the open slots instead of booking when the requested one is taken. A call with an idempotencyKey skips that check so a retry gets the original result back. Booking moves the lead to booked and notifies the coach\'s webhook subscribers, so pass confirm: true only after the coach approves this exact slot. Example: call with { leadId, slotAt: "2026-07-14T15:00:00Z", confirm: true } after the lead agreed to a time.',
|
|
56
122
|
inputSchema: {
|
|
57
123
|
...WORKSPACE_INPUT_FIELDS,
|
|
58
|
-
leadId:
|
|
59
|
-
slotAt:
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
.
|
|
67
|
-
|
|
124
|
+
leadId: NonEmptyString.annotate({ description: "The lead to book." }),
|
|
125
|
+
slotAt: IsoDateTime.annotate({
|
|
126
|
+
description: "ISO-8601 start time for the call, with an offset.",
|
|
127
|
+
}),
|
|
128
|
+
provider: Schema.optionalKey(Schema.Literals(BOOKING_PROVIDERS).annotate({
|
|
129
|
+
description: "The coach's booking provider. Sent with the booking and used for the availability check. Pass it when the coach is not on Calendly, or the availability check reads the wrong calendar. Left off, the check reads Calendly and the API records the booking as manual.",
|
|
130
|
+
})),
|
|
131
|
+
bookingTypeId: Schema.optionalKey(NonEmptyString.annotate({
|
|
132
|
+
description: "The booking type to book under, from luca_bookings_types_list. Required when provider is google_calendar, and it must be an active google_calendar type.",
|
|
133
|
+
})),
|
|
134
|
+
...idempotencyKeyInput("Reuse the same key when retrying this exact booking. A keyed call skips the availability check and lets the API replay the first result."),
|
|
68
135
|
},
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
136
|
+
outputSchema: Schema.Union([
|
|
137
|
+
slotNotAvailable("booked"),
|
|
138
|
+
operationResult("bookings.create"),
|
|
139
|
+
]),
|
|
140
|
+
composes: [
|
|
141
|
+
"bookings.availability",
|
|
142
|
+
"bookings.managedAvailability",
|
|
143
|
+
"bookings.create",
|
|
144
|
+
],
|
|
145
|
+
run: (call, input) => Effect.gen(function* () {
|
|
73
146
|
const slotAt = stringInput(input.slotAt);
|
|
74
|
-
|
|
147
|
+
// A booking type has its own schedule, which the provider route knows
|
|
148
|
+
// nothing of.
|
|
149
|
+
const open = yield* openSlotsAt(input, slotAt, (window) => input.bookingTypeId === undefined
|
|
150
|
+
? providerSlots(call, input, window)
|
|
151
|
+
: bookingTypeSlots(call, stringInput(input.bookingTypeId), window));
|
|
75
152
|
// Refuse only on a confident "that slot is not open". Handing back the
|
|
76
153
|
// real slots lets the agent pick again in one turn instead of writing a
|
|
77
154
|
// booking the provider will reject later.
|
|
@@ -84,48 +161,61 @@ const bookCall = {
|
|
|
84
161
|
openSlots: open.value,
|
|
85
162
|
};
|
|
86
163
|
}
|
|
87
|
-
return yield*
|
|
88
|
-
body: {
|
|
89
|
-
|
|
164
|
+
return yield* call("bookings.create", {
|
|
165
|
+
body: {
|
|
166
|
+
leadId: input.leadId,
|
|
167
|
+
slotAt: input.slotAt,
|
|
168
|
+
...optionalField("provider", input.provider),
|
|
169
|
+
...optionalField("bookingTypeId", input.bookingTypeId),
|
|
170
|
+
},
|
|
171
|
+
});
|
|
90
172
|
}),
|
|
91
|
-
};
|
|
173
|
+
});
|
|
92
174
|
/** How long a reschedule link stays usable when the caller names no window. */
|
|
93
175
|
const DEFAULT_GUEST_LINK_HOURS = 168;
|
|
94
|
-
const rescheduleCall = {
|
|
176
|
+
const rescheduleCall = taskTool({
|
|
95
177
|
name: "luca_reschedule_call",
|
|
96
178
|
title: "Move a call to a new time",
|
|
97
|
-
description: 'Move a booked call to a new time and hand back a fresh reschedule link for the lead. Checks the new slot is open first and returns the open slots instead of moving the call when it is taken. Example: call with { bookingId, slotAt: "2026-07-16T15:00:00Z" } after the lead asked to push the call.
|
|
179
|
+
description: 'Move a booked call to a new time and hand back a fresh reschedule link for the lead. Checks the new slot is open first, against the schedule of its booking type when it has one, and returns the open slots instead of moving the call when it is taken. A call with an idempotencyKey skips that check so a retry gets the original result back. Moving the call updates the connected calendar and re-arms the lead\'s reminder for the new time, so pass confirm: true only after the coach approves the move. Example: call with { bookingId, slotAt: "2026-07-16T15:00:00Z", confirm: true } after the lead asked to push the call. No message about the move is sent: send the lead the link yourself, or draft the message through the review queue.',
|
|
98
180
|
inputSchema: {
|
|
99
181
|
...WORKSPACE_INPUT_FIELDS,
|
|
100
|
-
bookingId:
|
|
101
|
-
slotAt:
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
.
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
.optional()
|
|
112
|
-
.describe(`How long the new reschedule link stays usable. Defaults to ${DEFAULT_GUEST_LINK_HOURS} hours.`),
|
|
113
|
-
idempotencyKey: z
|
|
114
|
-
.string()
|
|
115
|
-
.optional()
|
|
116
|
-
.describe("Reuse the same key when retrying this exact reschedule."),
|
|
182
|
+
bookingId: NonEmptyString.annotate({ description: "The booking to move." }),
|
|
183
|
+
slotAt: IsoDateTime.annotate({
|
|
184
|
+
description: "ISO-8601 start time to move the call to, with an offset.",
|
|
185
|
+
}),
|
|
186
|
+
provider: Schema.optionalKey(Schema.Literals(BOOKING_PROVIDERS).annotate({
|
|
187
|
+
description: "The coach's booking provider. Used only for the availability check, and only when the booking has no booking type. The booking keeps the provider it already has. Pass it when the coach is not on Calendly, or the availability check reads the wrong calendar.",
|
|
188
|
+
})),
|
|
189
|
+
expiresInHours: Schema.optionalKey(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 720 })).annotate({
|
|
190
|
+
description: `How long the new reschedule link stays usable. Defaults to ${DEFAULT_GUEST_LINK_HOURS} hours.`,
|
|
191
|
+
})),
|
|
192
|
+
...idempotencyKeyInput("Reuse the same key when retrying this exact reschedule. A keyed call skips the availability check and lets the API replay the first result."),
|
|
117
193
|
},
|
|
118
|
-
|
|
119
|
-
|
|
194
|
+
outputSchema: Schema.Union([
|
|
195
|
+
slotNotAvailable("rescheduled"),
|
|
196
|
+
Schema.Struct({
|
|
197
|
+
rescheduled: Schema.Literal(true),
|
|
198
|
+
booking: operationResult("bookings.update"),
|
|
199
|
+
guestLinks: resultOrError("bookings.guestLinks.create", "guestLinksError"),
|
|
200
|
+
}),
|
|
201
|
+
]),
|
|
120
202
|
composes: [
|
|
203
|
+
"bookings.get",
|
|
121
204
|
"bookings.availability",
|
|
205
|
+
"bookings.managedAvailability",
|
|
122
206
|
"bookings.update",
|
|
123
207
|
"bookings.guestLinks.create",
|
|
124
208
|
],
|
|
125
|
-
run: (
|
|
209
|
+
run: (call, input) => Effect.gen(function* () {
|
|
126
210
|
const slotAt = stringInput(input.slotAt);
|
|
127
211
|
const bookingId = stringInput(input.bookingId);
|
|
128
|
-
|
|
212
|
+
// A booking made under a booking type keeps that type's own schedule, so
|
|
213
|
+
// the provider calendar would judge the new slot against the wrong
|
|
214
|
+
// availability. The read happens only when the availability check does.
|
|
215
|
+
const open = yield* openSlotsAt(input, slotAt, (window) => Effect.flatMap(bookingTypeOf(call, bookingId), (bookingType) => Option.match(Option.flatMapNullishOr(bookingType, (id) => id), {
|
|
216
|
+
onNone: () => providerSlots(call, input, window),
|
|
217
|
+
onSome: (bookingTypeId) => bookingTypeSlots(call, bookingTypeId, window),
|
|
218
|
+
})));
|
|
129
219
|
if (Option.isSome(open) &&
|
|
130
220
|
!open.value.some((candidate) => sameInstant(candidate, slotAt))) {
|
|
131
221
|
return {
|
|
@@ -135,23 +225,20 @@ const rescheduleCall = {
|
|
|
135
225
|
openSlots: open.value,
|
|
136
226
|
};
|
|
137
227
|
}
|
|
138
|
-
const booking = yield*
|
|
228
|
+
const booking = yield* call("bookings.update", {
|
|
139
229
|
pathParams: { id: bookingId },
|
|
140
230
|
body: { slotAt },
|
|
141
|
-
})
|
|
231
|
+
});
|
|
142
232
|
// The call has already moved by the time the link is minted. Failing the
|
|
143
233
|
// whole tool here would tell the agent the reschedule did not happen,
|
|
144
234
|
// when the only thing missing is a link it can mint again.
|
|
145
|
-
const guestLinks = yield*
|
|
235
|
+
const guestLinks = yield* failureAsField("guestLinksError", call("bookings.guestLinks.create", {
|
|
146
236
|
pathParams: { id: bookingId },
|
|
147
237
|
body: {
|
|
148
238
|
expiresInHours: numberInput(input.expiresInHours, DEFAULT_GUEST_LINK_HOURS),
|
|
149
239
|
},
|
|
150
|
-
}))
|
|
240
|
+
}));
|
|
151
241
|
return { rescheduled: true, booking, guestLinks };
|
|
152
242
|
}),
|
|
153
|
-
};
|
|
154
|
-
export const bookingTaskTools = [
|
|
155
|
-
bookCall,
|
|
156
|
-
rescheduleCall,
|
|
157
|
-
];
|
|
243
|
+
});
|
|
244
|
+
export const bookingTaskTools = [bookCall, rescheduleCall];
|