@setluca/mcp 0.1.1 → 0.2.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.
Files changed (63) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/README.md +178 -80
  3. package/dist/annotations.js +45 -0
  4. package/dist/completions.js +110 -0
  5. package/dist/config.js +55 -29
  6. package/dist/errors.js +56 -15
  7. package/dist/generated/openapi-inputs.js +4324 -1429
  8. package/dist/generated/openapi-outputs.js +21992 -8516
  9. package/dist/generated/route-catalog.js +1480 -0
  10. package/dist/http.js +97 -103
  11. package/dist/index.js +16 -10
  12. package/dist/oauth-resolver.js +59 -37
  13. package/dist/observability.js +12 -4
  14. package/dist/openapi-zod.js +138 -72
  15. package/dist/operations/catalog.js +50 -0
  16. package/dist/operations/groups/analytics.js +122 -0
  17. package/dist/operations/groups/bookings.js +189 -0
  18. package/dist/operations/groups/broadcasts.js +106 -0
  19. package/dist/operations/groups/cadences.js +14 -0
  20. package/dist/operations/groups/callEvents.js +131 -0
  21. package/dist/operations/groups/campaigns.js +132 -0
  22. package/dist/operations/groups/capabilities.js +8 -0
  23. package/dist/operations/groups/coach.js +43 -0
  24. package/dist/operations/groups/conversations.js +48 -0
  25. package/dist/operations/groups/insights.js +33 -0
  26. package/dist/operations/groups/integrations.js +80 -0
  27. package/dist/operations/groups/knowledge.js +34 -0
  28. package/dist/operations/groups/leads.js +101 -0
  29. package/dist/operations/groups/learning.js +24 -0
  30. package/dist/operations/groups/memory.js +27 -0
  31. package/dist/operations/groups/reports.js +14 -0
  32. package/dist/operations/groups/reviewQueue.js +57 -0
  33. package/dist/operations/groups/safety.js +8 -0
  34. package/dist/operations/groups/usage.js +13 -0
  35. package/dist/operations/groups/voice.js +14 -0
  36. package/dist/operations/groups/webhooks.js +76 -0
  37. package/dist/operations/manifest.js +40 -0
  38. package/dist/operations/registry.js +331 -0
  39. package/dist/operations.js +11 -780
  40. package/dist/optional-field.js +12 -0
  41. package/dist/page-shape.js +62 -0
  42. package/dist/pagination.js +165 -0
  43. package/dist/prompts.js +89 -8
  44. package/dist/provenance.js +18 -2
  45. package/dist/remote.js +80 -49
  46. package/dist/resilience.js +127 -0
  47. package/dist/resources.js +17 -2
  48. package/dist/scopes.js +154 -0
  49. package/dist/serialization.js +36 -0
  50. package/dist/server.js +258 -316
  51. package/dist/task-tools/analytics.js +168 -0
  52. package/dist/task-tools/bookings.js +157 -0
  53. package/dist/task-tools/cadences.js +65 -0
  54. package/dist/task-tools/calls.js +202 -0
  55. package/dist/task-tools/inbox.js +120 -0
  56. package/dist/task-tools/leads.js +123 -0
  57. package/dist/task-tools/runtime.js +92 -0
  58. package/dist/task-tools.js +21 -0
  59. package/dist/tool-cache.js +39 -0
  60. package/dist/tool.js +228 -0
  61. package/dist/tsconfig.tsbuildinfo +1 -1
  62. package/dist/version.js +14 -0
  63. package/package.json +38 -34
@@ -0,0 +1,168 @@
1
+ import { Effect, Record } from "effect";
2
+ import * as z from "zod/v4";
3
+ import { formatError } from "../errors.js";
4
+ import { WORKSPACE_INPUT_FIELDS } from "../operations.js";
5
+ import { optionalField } from "../optional-field.js";
6
+ import { listingIds, numberInput, taskRequest, } from "./runtime.js";
7
+ const DEFAULT_CAMPAIGN_LIMIT = 5;
8
+ 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
+ const ANALYTICS_PERIOD_VALUES = [
58
+ "this_week",
59
+ "this_month",
60
+ "last_week",
61
+ "last_month",
62
+ "last_30_days",
63
+ ];
64
+ /**
65
+ * 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.
69
+ */
70
+ const ANALYTICS_READS = {
71
+ funnel: { key: "funnel", id: "analytics.funnel", periodScoped: true },
72
+ revenue: { key: "revenue", id: "analytics.revenue", periodScoped: true },
73
+ speedImpact: {
74
+ key: "speedImpact",
75
+ id: "analytics.speedImpact",
76
+ periodScoped: true,
77
+ },
78
+ forecast: {
79
+ key: "forecast",
80
+ id: "analytics.forecast",
81
+ periodScoped: false,
82
+ },
83
+ callIntelligence: {
84
+ key: "callIntelligence",
85
+ id: "analytics.callIntelligence",
86
+ periodScoped: true,
87
+ },
88
+ ghostedLeads: {
89
+ key: "ghostedLeads",
90
+ id: "analytics.ghostedLeads",
91
+ periodScoped: true,
92
+ },
93
+ trustScore: {
94
+ key: "trustScore",
95
+ id: "analytics.trustScore",
96
+ periodScoped: true,
97
+ },
98
+ };
99
+ /** The five reads luca_analytics_magic_monday composes, always this_week. */
100
+ const MAGIC_MONDAY_READS = [
101
+ ANALYTICS_READS.funnel,
102
+ ANALYTICS_READS.revenue,
103
+ ANALYTICS_READS.speedImpact,
104
+ ANALYTICS_READS.forecast,
105
+ ANALYTICS_READS.callIntelligence,
106
+ ];
107
+ /** magic_monday's five reads plus the two extra deep-dive-only reads. */
108
+ const DEEP_DIVE_READS = [
109
+ ...MAGIC_MONDAY_READS,
110
+ ANALYTICS_READS.ghostedLeads,
111
+ ANALYTICS_READS.trustScore,
112
+ ];
113
+ /**
114
+ * Run a list of analytics reads in parallel and return them keyed by `key`.
115
+ * Effect.all defaults to concurrency 1 (sequential); these reads don't depend
116
+ * on each other, so serializing them would only add latency, not correctness.
117
+ */
118
+ function analyticsFanOut(api, operationById, input, period, reads) {
119
+ return Effect.all(reads.map((read) => api
120
+ .request(taskRequest(operationById(read.id), input, {
121
+ ...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
+ }
125
+ const magicMonday = {
126
+ name: "luca_analytics_magic_monday",
127
+ title: "Get the Magic Monday report",
128
+ 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
+ inputSchema: {
130
+ ...WORKSPACE_INPUT_FIELDS,
131
+ },
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 = {
138
+ name: "luca_analytics_deep_dive",
139
+ title: "Run a deep-dive analytics investigation",
140
+ 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
+ inputSchema: {
142
+ ...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."),
152
+ },
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);
162
+ },
163
+ };
164
+ export const analyticsTaskTools = [
165
+ getAnalytics,
166
+ magicMonday,
167
+ analyticsDeepDive,
168
+ ];
@@ -0,0 +1,157 @@
1
+ import { Effect, Option } from "effect";
2
+ import * as z from "zod/v4";
3
+ import { WORKSPACE_INPUT_FIELDS } from "../operations.js";
4
+ import { optionalField } from "../optional-field.js";
5
+ import { independentWrite, numberInput, stringInput, taskRequest, } from "./runtime.js";
6
+ /** How far past the requested slot to look when offering alternatives. */
7
+ const AVAILABILITY_WINDOW_MS = 24 * 60 * 60 * 1000;
8
+ /**
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
13
+ * `"provider_error"` when it could not reach the provider, and it defaults to
14
+ * Calendly when the caller names no provider, so a coach on Cal.com asking
15
+ * without one gets a confident-looking answer about the wrong calendar. Only a
16
+ * `live_provider` read is worth blocking a booking over.
17
+ */
18
+ const openSlotsAt = (api, operationById, input, slotAt) => Effect.gen(function* () {
19
+ const start = new Date(slotAt);
20
+ if (Number.isNaN(start.getTime())) {
21
+ return Option.none();
22
+ }
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
+ })),
30
+ // 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() })),
42
+ });
43
+ /**
44
+ * Whether two ISO-8601 instants name the same moment, whatever their offset.
45
+ *
46
+ * An unparseable instant is `NaN`, which is not equal to itself, so a garbled
47
+ * slot on either side answers false without a guard of its own.
48
+ */
49
+ function sameInstant(left, right) {
50
+ return new Date(left).getTime() === new Date(right).getTime();
51
+ }
52
+ const bookCall = {
53
+ name: "luca_book_call",
54
+ 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.',
56
+ inputSchema: {
57
+ ...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."),
68
+ },
69
+ readOnly: false,
70
+ untrustedContent: false,
71
+ composes: ["bookings.availability", "bookings.create"],
72
+ run: (api, operationById, input) => Effect.gen(function* () {
73
+ const slotAt = stringInput(input.slotAt);
74
+ const open = yield* openSlotsAt(api, operationById, input, slotAt);
75
+ // Refuse only on a confident "that slot is not open". Handing back the
76
+ // real slots lets the agent pick again in one turn instead of writing a
77
+ // booking the provider will reject later.
78
+ if (Option.isSome(open) &&
79
+ !open.value.some((candidate) => sameInstant(candidate, slotAt))) {
80
+ return {
81
+ booked: false,
82
+ reason: "slot_not_available",
83
+ requestedSlotAt: slotAt,
84
+ openSlots: open.value,
85
+ };
86
+ }
87
+ return yield* api.request(taskRequest(operationById("bookings.create"), input, {
88
+ body: { leadId: input.leadId, slotAt: input.slotAt },
89
+ }));
90
+ }),
91
+ };
92
+ /** How long a reschedule link stays usable when the caller names no window. */
93
+ const DEFAULT_GUEST_LINK_HOURS = 168;
94
+ const rescheduleCall = {
95
+ name: "luca_reschedule_call",
96
+ 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.',
98
+ inputSchema: {
99
+ ...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."),
117
+ },
118
+ readOnly: false,
119
+ untrustedContent: false,
120
+ composes: [
121
+ "bookings.availability",
122
+ "bookings.update",
123
+ "bookings.guestLinks.create",
124
+ ],
125
+ run: (api, operationById, input) => Effect.gen(function* () {
126
+ const slotAt = stringInput(input.slotAt);
127
+ const bookingId = stringInput(input.bookingId);
128
+ const open = yield* openSlotsAt(api, operationById, input, slotAt);
129
+ if (Option.isSome(open) &&
130
+ !open.value.some((candidate) => sameInstant(candidate, slotAt))) {
131
+ return {
132
+ rescheduled: false,
133
+ reason: "slot_not_available",
134
+ requestedSlotAt: slotAt,
135
+ openSlots: open.value,
136
+ };
137
+ }
138
+ const booking = yield* api.request(taskRequest(operationById("bookings.update"), input, {
139
+ pathParams: { id: bookingId },
140
+ body: { slotAt },
141
+ }));
142
+ // The call has already moved by the time the link is minted. Failing the
143
+ // whole tool here would tell the agent the reschedule did not happen,
144
+ // 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, {
146
+ pathParams: { id: bookingId },
147
+ body: {
148
+ expiresInHours: numberInput(input.expiresInHours, DEFAULT_GUEST_LINK_HOURS),
149
+ },
150
+ })));
151
+ return { rescheduled: true, booking, guestLinks };
152
+ }),
153
+ };
154
+ export const bookingTaskTools = [
155
+ bookCall,
156
+ rescheduleCall,
157
+ ];
@@ -0,0 +1,65 @@
1
+ import * as z from "zod/v4";
2
+ import { confirmInput, WORKSPACE_INPUT_FIELDS } from "../operations.js";
3
+ import { stringInput, taskRequest, textBody, } from "./runtime.js";
4
+ const rescueSilentLeads = {
5
+ name: "luca_rescue_silent_leads",
6
+ title: "Enroll silent leads into the rescue cadence",
7
+ description: 'Enroll one or more silent leads into the multi-day rescue cadence. THIS STARTS OUTBOUND TOUCHES to real leads over the coming days — pass confirm: true to proceed. cadenceTemplate is light | standard | aggressive (default standard). Example: { leadIds: ["..."], cadenceTemplate: "standard", confirm: true }.',
8
+ inputSchema: {
9
+ ...WORKSPACE_INPUT_FIELDS,
10
+ leadIds: z
11
+ .array(z.string())
12
+ .min(1)
13
+ .max(100)
14
+ .describe("Lead ids to enroll (max 100). Non-tenant ids are skipped."),
15
+ cadenceTemplate: z
16
+ .enum(["light", "standard", "aggressive"])
17
+ .optional()
18
+ .describe("Cadence intensity (default standard)."),
19
+ ...confirmInput,
20
+ idempotencyKey: z
21
+ .string()
22
+ .optional()
23
+ .describe("Reuse the same key when retrying this exact enrollment."),
24
+ },
25
+ readOnly: false,
26
+ untrustedContent: false,
27
+ confirmRequired: true,
28
+ composes: ["cadences.rescueStart"],
29
+ run: (api, operationById, input) => api.request(taskRequest(operationById("cadences.rescueStart"), input, {
30
+ body: {
31
+ leadIds: input.leadIds,
32
+ ...textBody("cadenceTemplate", input.cadenceTemplate),
33
+ },
34
+ })),
35
+ };
36
+ const pauseCadence = {
37
+ name: "luca_pause_cadence",
38
+ title: "Pause a lead's rescue cadence",
39
+ description: 'Cancel a lead\'s running rescue cadence so no further touches are sent. Safe to call twice (a no-op once cancelled). Example: call with { leadId, reason: "lead replied elsewhere" }.',
40
+ inputSchema: {
41
+ ...WORKSPACE_INPUT_FIELDS,
42
+ leadId: z.string().min(1).describe("The lead whose cadence to pause."),
43
+ reason: z
44
+ .string()
45
+ .min(1)
46
+ .max(500)
47
+ .optional()
48
+ .describe("Optional operator reason, stored on the cadence."),
49
+ idempotencyKey: z
50
+ .string()
51
+ .optional()
52
+ .describe("Reuse the same key when retrying this exact pause."),
53
+ },
54
+ readOnly: false,
55
+ untrustedContent: false,
56
+ composes: ["cadences.pause"],
57
+ run: (api, operationById, input) => api.request(taskRequest(operationById("cadences.pause"), input, {
58
+ pathParams: { id: stringInput(input.leadId) },
59
+ body: textBody("reason", input.reason),
60
+ })),
61
+ };
62
+ export const cadenceTaskTools = [
63
+ rescueSilentLeads,
64
+ pauseCadence,
65
+ ];
@@ -0,0 +1,202 @@
1
+ import { Effect, Option, Schema } from "effect";
2
+ import * as z from "zod/v4";
3
+ import { WORKSPACE_INPUT_FIELDS } from "../operations.js";
4
+ import { optionalField } from "../optional-field.js";
5
+ import { independentWrite, stringInput, taskRequest, textBody, } from "./runtime.js";
6
+ const postCallQueue = {
7
+ name: "luca_post_call_queue",
8
+ title: "List the post-call work",
9
+ description: "Return everything waiting on the coach after a call: calls that still need a report on how they went, and attended calls with no revenue outcome recorded. The two halves are tracked separately by the API, so a call can sit on one list and not the other. Close an item with luca_close_call_loop. Example: call with {} after a day of calls.",
10
+ inputSchema: {
11
+ ...WORKSPACE_INPUT_FIELDS,
12
+ },
13
+ readOnly: true,
14
+ untrustedContent: true,
15
+ composes: ["callEvents.needingFeedback", "bookings.needsOutcome"],
16
+ run: (api, operationById, input) =>
17
+ // Neither read depends on the other, and Effect.all defaults to
18
+ // concurrency 1, so without this the second waits on the first for nothing.
19
+ Effect.all({
20
+ needingFeedback: api.request(taskRequest(operationById("callEvents.needingFeedback"), input)),
21
+ needingOutcome: api.request(taskRequest(operationById("bookings.needsOutcome"), input)),
22
+ }, { concurrency: "unbounded" }),
23
+ };
24
+ /**
25
+ * The id of the call event attached to a booking, if the API has one. A booking
26
+ * whose call has not happened yet answers 404, which is the normal case rather
27
+ * than a failure. So is any other read error here, because the outcome write
28
+ * below is worth doing on its own.
29
+ */
30
+ const CallEventId = Schema.Struct({
31
+ callEvent: Schema.Struct({ id: Schema.String }),
32
+ });
33
+ const decodeCallEventId = Schema.decodeUnknownOption(CallEventId);
34
+ const ATTENDANCE_VALUES = [
35
+ "completed",
36
+ "no_show",
37
+ "rescheduled",
38
+ "cancelled",
39
+ ];
40
+ const OUTCOME_VALUES = ["won", "lost", "open"];
41
+ /**
42
+ * The revenue half of the body, shaped by the status it carries. The API takes
43
+ * one of three variants and rejects a mix, so building it here keeps the tool
44
+ * from sending, say, an amount alongside a loss reason.
45
+ */
46
+ function outcomeBody(input) {
47
+ const common = {
48
+ status: input.status,
49
+ ...textBody("note", input.note),
50
+ ...optionalField("expectedVersion", input.expectedVersion),
51
+ };
52
+ if (input.status === "won") {
53
+ return {
54
+ ...common,
55
+ ...optionalField("amountMinor", input.amountMinor),
56
+ ...textBody("currency", input.currency),
57
+ };
58
+ }
59
+ if (input.status === "lost") {
60
+ return {
61
+ ...common,
62
+ lossReasonDefinitionId: stringInput(input.lossReasonDefinitionId),
63
+ };
64
+ }
65
+ return {
66
+ ...common,
67
+ nextFollowUpAt: stringInput(input.nextFollowUpAt),
68
+ followUpTimezone: stringInput(input.followUpTimezone),
69
+ };
70
+ }
71
+ const closeCallLoop = {
72
+ name: "luca_close_call_loop",
73
+ title: "Close the loop on a call",
74
+ description: 'File what happened on a call and what it was worth, in one call. Reports attendance on the call event (completed, no_show, rescheduled, or cancelled) and, when an outcome is given, records the booking outcome: won with an amount, lost with a reason id from luca_bookings_outcomeReasons_list, or open with the next follow-up. Nothing is sent to the lead. Example: { bookingId, attendance: "completed", outcome: "won", amountMinor: 250000, currency: "USD" }.',
75
+ inputSchema: {
76
+ ...WORKSPACE_INPUT_FIELDS,
77
+ bookingId: z
78
+ .string()
79
+ .min(1)
80
+ .describe("The booking whose call just happened."),
81
+ attendance: z
82
+ .enum(ATTENDANCE_VALUES)
83
+ .describe("What happened to the call itself."),
84
+ rating: z
85
+ .number()
86
+ .int()
87
+ .min(1)
88
+ .max(5)
89
+ .optional()
90
+ .describe("The coach's 1-5 rating of the call."),
91
+ notes: z
92
+ .string()
93
+ .min(1)
94
+ .max(4000)
95
+ .optional()
96
+ .describe("The coach's notes on the call, stored on the call event."),
97
+ outcome: z
98
+ .enum(OUTCOME_VALUES)
99
+ .optional()
100
+ .describe("The revenue outcome. Leave it off to report attendance only and record the money later."),
101
+ amountMinor: z
102
+ .number()
103
+ .int()
104
+ .min(0)
105
+ .optional()
106
+ .describe("Deal value in minor units (cents) when outcome is won."),
107
+ currency: z
108
+ .string()
109
+ .length(3)
110
+ .optional()
111
+ .describe("ISO-4217 currency for amountMinor, uppercase."),
112
+ lossReasonDefinitionId: z
113
+ .string()
114
+ .optional()
115
+ .describe("Required when outcome is lost: a reason id from luca_bookings_outcomeReasons_list."),
116
+ nextFollowUpAt: z
117
+ .string()
118
+ .optional()
119
+ .describe("Required when outcome is open: ISO-8601 follow-up time."),
120
+ followUpTimezone: z
121
+ .string()
122
+ .optional()
123
+ .describe("Required when outcome is open: IANA timezone the follow-up is scheduled in."),
124
+ outcomeNote: z
125
+ .string()
126
+ .min(1)
127
+ .max(5000)
128
+ .optional()
129
+ .describe("Note stored on the revenue outcome, separate from notes."),
130
+ expectedVersion: z
131
+ .number()
132
+ .int()
133
+ .min(0)
134
+ .optional()
135
+ .describe("The outcome version you read, when correcting an earlier outcome. A stale version returns 409 with the current one."),
136
+ idempotencyKey: z
137
+ .string()
138
+ .optional()
139
+ .describe("Reuse the same key when retrying this exact report. Both writes are scoped by route, so one key covers the pair."),
140
+ },
141
+ readOnly: false,
142
+ untrustedContent: true,
143
+ composes: [
144
+ "callEvents.byBooking",
145
+ "callEvents.feedback",
146
+ "bookings.outcome.record",
147
+ ],
148
+ run: (api, operationById, input) => Effect.gen(function* () {
149
+ const bookingId = stringInput(input.bookingId);
150
+ const page = yield* Effect.catchAll(api.request(taskRequest(operationById("callEvents.byBooking"), input, {
151
+ pathParams: { bookingId },
152
+ })), () => Effect.succeed(null));
153
+ const feedback = yield* Option.match(decodeCallEventId(page), {
154
+ onNone: () =>
155
+ // No call event means attendance has nowhere to go. The revenue write
156
+ // below still runs, and that is the half the coach is usually after.
157
+ Effect.succeed({
158
+ reported: false,
159
+ reason: "no_call_event_for_booking",
160
+ }),
161
+ onSome: (decoded) =>
162
+ // The revenue write below is worth doing even when the attendance
163
+ // report fails, so this failure becomes a field rather than ending
164
+ // the tool call.
165
+ independentWrite("feedbackError", api.request(taskRequest(operationById("callEvents.feedback"), input, {
166
+ pathParams: { id: decoded.callEvent.id },
167
+ body: {
168
+ outcome: input.attendance,
169
+ ...optionalField("rating", input.rating),
170
+ ...textBody("notes", input.notes),
171
+ },
172
+ }))),
173
+ });
174
+ const outcomeStatus = z
175
+ .enum(OUTCOME_VALUES)
176
+ .safeParse(input.outcome ?? undefined);
177
+ if (!outcomeStatus.success) {
178
+ return { feedback };
179
+ }
180
+ // The attendance report already landed. Failing the whole tool here
181
+ // would tell the agent nothing was written and invite a retry that files
182
+ // the attendance twice under a fresh key.
183
+ const outcome = yield* independentWrite("outcomeError", api.request(taskRequest(operationById("bookings.outcome.record"), input, {
184
+ pathParams: { id: bookingId },
185
+ body: outcomeBody({
186
+ status: outcomeStatus.data,
187
+ note: input.outcomeNote,
188
+ amountMinor: input.amountMinor,
189
+ currency: input.currency,
190
+ lossReasonDefinitionId: input.lossReasonDefinitionId,
191
+ nextFollowUpAt: input.nextFollowUpAt,
192
+ followUpTimezone: input.followUpTimezone,
193
+ expectedVersion: input.expectedVersion,
194
+ }),
195
+ })));
196
+ return { feedback, outcome };
197
+ }),
198
+ };
199
+ export const callTaskTools = [
200
+ postCallQueue,
201
+ closeCallLoop,
202
+ ];