@setluca/mcp 0.2.1 → 0.3.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 (69) hide show
  1. package/CHANGELOG.md +316 -0
  2. package/LICENSE +21 -0
  3. package/README.md +19 -288
  4. package/dist/annotations.js +33 -18
  5. package/dist/client-address.js +64 -0
  6. package/dist/completions.js +24 -27
  7. package/dist/config.js +89 -38
  8. package/dist/errors.js +94 -13
  9. package/dist/fields.js +68 -0
  10. package/dist/fixed-window.js +46 -0
  11. package/dist/generated/openapi-inputs.js +216 -5820
  12. package/dist/generated/openapi-outputs.js +19014 -14001
  13. package/dist/generated/route-catalog.js +80 -0
  14. package/dist/http.js +103 -41
  15. package/dist/index.js +4 -1
  16. package/dist/oauth-challenge.js +74 -0
  17. package/dist/oauth-resolver.js +58 -61
  18. package/dist/observability.js +30 -15
  19. package/dist/openapi-schema.js +175 -0
  20. package/dist/operations/catalog.js +17 -0
  21. package/dist/operations/groups/analytics.js +20 -10
  22. package/dist/operations/groups/bookings.js +43 -15
  23. package/dist/operations/groups/broadcasts.js +29 -58
  24. package/dist/operations/groups/cadences.js +3 -1
  25. package/dist/operations/groups/callEvents.js +19 -72
  26. package/dist/operations/groups/campaigns.js +28 -32
  27. package/dist/operations/groups/channels.js +23 -0
  28. package/dist/operations/groups/conversations.js +14 -25
  29. package/dist/operations/groups/insights.js +4 -0
  30. package/dist/operations/groups/integrations.js +15 -17
  31. package/dist/operations/groups/knowledge.js +5 -1
  32. package/dist/operations/groups/leads.js +22 -23
  33. package/dist/operations/groups/learning.js +13 -1
  34. package/dist/operations/groups/memory.js +4 -6
  35. package/dist/operations/groups/reports.js +1 -1
  36. package/dist/operations/groups/reviewQueue.js +17 -14
  37. package/dist/operations/groups/safety.js +1 -1
  38. package/dist/operations/groups/voice.js +0 -1
  39. package/dist/operations/groups/webhooks.js +15 -3
  40. package/dist/operations/manifest.js +7 -6
  41. package/dist/operations/registry.js +102 -108
  42. package/dist/operations.js +4 -3
  43. package/dist/optional-field.js +15 -3
  44. package/dist/page-contract.js +74 -0
  45. package/dist/pagination.js +147 -67
  46. package/dist/prompts.js +122 -97
  47. package/dist/rate-limit.js +40 -0
  48. package/dist/remote.js +145 -65
  49. package/dist/resilience.js +107 -52
  50. package/dist/resolvers.js +125 -0
  51. package/dist/scopes.js +70 -17
  52. package/dist/serialization.js +32 -22
  53. package/dist/server.js +148 -132
  54. package/dist/standard-schema.js +215 -0
  55. package/dist/task-tools/analytics.js +145 -86
  56. package/dist/task-tools/bookings.js +168 -81
  57. package/dist/task-tools/cadences.js +30 -45
  58. package/dist/task-tools/calls.js +131 -124
  59. package/dist/task-tools/inbox.js +37 -47
  60. package/dist/task-tools/leads.js +49 -77
  61. package/dist/task-tools/runtime.js +75 -15
  62. package/dist/token-verification.js +42 -0
  63. package/dist/tool-cache.js +3 -2
  64. package/dist/tool.js +151 -96
  65. package/dist/version.js +1 -1
  66. package/package.json +37 -16
  67. package/dist/openapi-zod.js +0 -335
  68. package/dist/page-shape.js +0 -62
  69. package/dist/tsconfig.tsbuildinfo +0 -1
@@ -1,59 +1,15 @@
1
- import { Effect, Record } from "effect";
2
- import * as z from "zod/v4";
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 "../operations.js";
8
+ import { NonEmptyString, WORKSPACE_INPUT_FIELDS } from "../fields.js";
5
9
  import { optionalField } from "../optional-field.js";
6
- import { listingIds, numberInput, taskRequest, } from "./runtime.js";
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 rather than
67
- * hand-listing operation ids a second time, so the two tools cannot drift
68
- * apart on order, ids, or which reads are period-scoped.
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(api, operationById, input, period, reads) {
119
- return Effect.all(reads.map((read) => api
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
- const magicMonday = {
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
- readOnly: true,
133
- untrustedContent: false,
134
- composes: MAGIC_MONDAY_READS.map((read) => read.id),
135
- run: (api, operationById, input) => analyticsFanOut(api, operationById, input, "this_week", MAGIC_MONDAY_READS),
136
- };
137
- const analyticsDeepDive = {
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: z
144
- .string()
145
- .min(1)
146
- .max(500)
147
- .describe("The coach's specific question driving this investigation, e.g. 'why did close rate drop this month?'. Not sent to any API — captured so the caller's reasoning stays anchored to what the coach actually asked."),
148
- period: z
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
- readOnly: true,
154
- untrustedContent: false,
155
- composes: DEEP_DIVE_READS.map((read) => read.id),
156
- run: (api, operationById, input) => {
157
- const parsedPeriod = z
158
- .enum(ANALYTICS_PERIOD_VALUES)
159
- .safeParse(input.period);
160
- const period = parsedPeriod.success ? parsedPeriod.data : "this_month";
161
- return analyticsFanOut(api, operationById, input, period, DEEP_DIVE_READS);
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 { Effect, Option } from "effect";
2
- import * as z from "zod/v4";
3
- import { WORKSPACE_INPUT_FIELDS } from "../operations.js";
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 { independentWrite, numberInput, stringInput, taskRequest, } from "./runtime.js";
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
- * The slots the provider reports as open in the window starting at `slotAt`.
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 is worth blocking a booking over.
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 = (api, operationById, input, slotAt) => Effect.gen(function* () {
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 page = yield* Effect.catchAll(api.request(taskRequest(operationById("bookings.availability"), input, {
24
- query: {
25
- start: start.toISOString(),
26
- end: new Date(start.getTime() + AVAILABILITY_WINDOW_MS).toISOString(),
27
- ...optionalField("provider", input.provider),
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
- () => Effect.succeed(null));
33
- const parsed = AvailabilityPageSchema.safeParse(page);
34
- if (!parsed.success || parsed.data.source !== "live_provider") {
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
- const bookCall = {
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: z.string().min(1).describe("The lead to book."),
59
- slotAt: z.string().describe("ISO-8601 start time for the call."),
60
- provider: z
61
- .string()
62
- .optional()
63
- .describe("The coach's booking provider. Pass it when the coach is not on Calendly, or the availability check reads the wrong calendar."),
64
- idempotencyKey: z
65
- .string()
66
- .optional()
67
- .describe("Reuse the same key when retrying this exact booking."),
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
- readOnly: false,
70
- untrustedContent: false,
71
- composes: ["bookings.availability", "bookings.create"],
72
- run: (api, operationById, input) => Effect.gen(function* () {
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
- const open = yield* openSlotsAt(api, operationById, input, slotAt);
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* api.request(taskRequest(operationById("bookings.create"), input, {
88
- body: { leadId: input.leadId, slotAt: input.slotAt },
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. The lead is not messaged \u2014 send them the link yourself, or draft the message through the review queue.',
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: z.string().min(1).describe("The booking to move."),
101
- slotAt: z.string().describe("ISO-8601 start time to move the call to."),
102
- provider: z
103
- .string()
104
- .optional()
105
- .describe("The coach's booking provider. Pass it when the coach is not on Calendly, or the availability check reads the wrong calendar."),
106
- expiresInHours: z
107
- .number()
108
- .int()
109
- .min(1)
110
- .max(720)
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
- readOnly: false,
119
- untrustedContent: false,
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: (api, operationById, input) => Effect.gen(function* () {
209
+ run: (call, input) => Effect.gen(function* () {
126
210
  const slotAt = stringInput(input.slotAt);
127
211
  const bookingId = stringInput(input.bookingId);
128
- const open = yield* openSlotsAt(api, operationById, input, slotAt);
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* api.request(taskRequest(operationById("bookings.update"), input, {
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* independentWrite("guestLinksError", api.request(taskRequest(operationById("bookings.guestLinks.create"), input, {
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];