@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
@@ -0,0 +1,175 @@
1
+ import * as Arr from "effect/Array";
2
+ import * as EffectJsonSchema from "effect/JsonSchema";
3
+ import * as P from "effect/Predicate";
4
+ import * as R from "effect/Record";
5
+ import * as Schema from "effect/Schema";
6
+ import * as SchemaRepresentation from "effect/SchemaRepresentation";
7
+ import { ISO_DATE_TIME_PATTERN, UUID_PATTERN } from "./fields.js";
8
+ import { optionalField, optionalFields } from "./optional-field.js";
9
+ /**
10
+ * Patterns for the string formats a tool argument is checked against. Effect
11
+ * keeps `format` as an annotation and does not validate it, so each format the
12
+ * server enforces is restated as a pattern. These are the three formats the
13
+ * server checked before the move to Effect Schema; `date` and `email` stay
14
+ * advisory and the API validates them.
15
+ */
16
+ const FORMAT_PATTERNS = {
17
+ uuid: UUID_PATTERN.source,
18
+ "date-time": ISO_DATE_TIME_PATTERN.source,
19
+ uri: "^[A-Za-z][A-Za-z0-9+.-]*:[^\\s]+$",
20
+ };
21
+ /** Whether a `format` is one {@link FORMAT_PATTERNS} enforces. */
22
+ const isEnforcedFormat = Schema.is(Schema.Literals(R.keys(FORMAT_PATTERNS)));
23
+ const isJsonLiteral = (value) => P.isString(value) ||
24
+ P.isNumber(value) ||
25
+ P.isBoolean(value) ||
26
+ P.isNull(value);
27
+ /**
28
+ * The shapes the API's OpenAPI 3.1 document does not use and this importer
29
+ * does not translate, each named so a schema that starts using one fails the
30
+ * generator with the keyword to handle rather than importing as something
31
+ * looser than it says.
32
+ */
33
+ const UNSUPPORTED_KEYWORDS = [
34
+ ["allOf", (node) => node.allOf !== undefined],
35
+ ["nullable (OpenAPI 3.0)", (node) => node.nullable !== undefined],
36
+ [
37
+ "a boolean exclusive bound (OpenAPI 3.0)",
38
+ (node) => P.isBoolean(node.exclusiveMinimum) || P.isBoolean(node.exclusiveMaximum),
39
+ ],
40
+ [
41
+ "an object or array enum or const value",
42
+ (node) => {
43
+ const values = node.enum;
44
+ const constant = node.const;
45
+ return ((Arr.isArray(values) && !Arr.every(values, isJsonLiteral)) ||
46
+ (constant !== undefined && !isJsonLiteral(constant)));
47
+ },
48
+ ],
49
+ [
50
+ "properties without type: object",
51
+ (node) => P.isObject(node.properties) && node.type === undefined,
52
+ ],
53
+ [
54
+ "an additionalProperties schema beside declared properties",
55
+ (node) => {
56
+ const extra = node.additionalProperties;
57
+ return (P.isObject(node.properties) &&
58
+ P.isObject(extra) &&
59
+ !R.isEmptyRecord(extra));
60
+ },
61
+ ],
62
+ ];
63
+ function unsupportedKeywords(node) {
64
+ return Arr.flatMap(UNSUPPORTED_KEYWORDS, ([keyword, appears]) => appears(node) ? [keyword] : []);
65
+ }
66
+ /**
67
+ * The extra-key rule for an object node. A declared object without the key is
68
+ * closed, `true` and a nested schema stay as they are, and any other value is
69
+ * malformed and closes the object rather than opening it.
70
+ */
71
+ function closeObject(node) {
72
+ const extra = node.additionalProperties;
73
+ if (P.isBoolean(extra) || P.isObject(extra)) {
74
+ return node;
75
+ }
76
+ return extra === undefined && !P.isObject(node.properties)
77
+ ? node
78
+ : { ...node, additionalProperties: false };
79
+ }
80
+ function formatPattern(node) {
81
+ const format = node.format;
82
+ if (!isEnforcedFormat(format) || node.pattern !== undefined) {
83
+ return node;
84
+ }
85
+ return { ...node, pattern: FORMAT_PATTERNS[format] };
86
+ }
87
+ /**
88
+ * Rewrites one JSON Schema node before Effect translates it, so the imported
89
+ * schema checks what this server has always checked:
90
+ *
91
+ * - `oneOf` is read as `anyOf`. Every `oneOf` in the manifest is a tagged
92
+ * union, so the two agree on real input, and `anyOf` cannot reject a value
93
+ * for matching two variants.
94
+ * - An object that declares properties and says nothing about others is
95
+ * closed, and so is one with a malformed `additionalProperties`.
96
+ * - The enforced formats gain the pattern from {@link FORMAT_PATTERNS}.
97
+ *
98
+ * A node using a shape {@link unsupportedKeywords} names throws instead.
99
+ * Every step leaves its own output alone, so running it twice on a node is the
100
+ * same as running it once.
101
+ */
102
+ function tightenNode(node) {
103
+ const unsupported = unsupportedKeywords(node);
104
+ if (Arr.isReadonlyArrayNonEmpty(unsupported)) {
105
+ // oxlint-disable-next-line effect/avoid-untagged-errors -- a schema the importer cannot translate is a build-time programming error, not a runtime failure
106
+ throw new Error(`Unsupported OpenAPI schema shape: ${unsupported.join(", ")}. Teach src/openapi-schema.ts to translate it.`);
107
+ }
108
+ const { oneOf, ...rest } = node;
109
+ return formatPattern(closeObject(oneOf === undefined ? rest : { ...rest, anyOf: oneOf }));
110
+ }
111
+ function importNode(node) {
112
+ // SAFETY: a schema translated from JSON Schema is built from plain
113
+ // structural schemas and checks, none of which require a service.
114
+ return SchemaRepresentation.fromJsonSchemaDocument(EffectJsonSchema.fromSchemaOpenApi3_1(node), { patterns: "apply", onEnter: tightenNode });
115
+ }
116
+ /**
117
+ * The Effect Schema for one OpenAPI JSON Schema node. Patterns in the document
118
+ * are applied, and {@link tightenNode} runs on every node first.
119
+ *
120
+ * Tool inputs go through this when `src/generated/openapi-inputs.ts` is
121
+ * generated, not at runtime, so a change here needs `bun run openapi:generate`.
122
+ * `openapi:inputs:check` fails until it runs.
123
+ */
124
+ export function schemaFromOpenApi(schema) {
125
+ return importNode(tightenNode(schema));
126
+ }
127
+ /** The node's type, with `object` assumed for a node that declares properties. */
128
+ function relaxedType(schema) {
129
+ const declared = schema.type ?? (schema.properties === undefined ? undefined : "object");
130
+ return declared === undefined ? {} : { type: declared };
131
+ }
132
+ /**
133
+ * Relax a response JSON schema to a **structural, type-only** schema for output
134
+ * validation. The goal is a check that fails loud on a genuinely malformed
135
+ * payload (a field typed as the wrong primitive, `result` that is not an object
136
+ * at all) while never rejecting a well-formed real result.
137
+ *
138
+ * So it keeps each field's base `type` but drops value-level
139
+ * constraints, meaning `format`, `pattern`, length and range bounds, `enum`,
140
+ * and array-item schemas, and makes every object open with no required
141
+ * properties. Those constraints are the false-positive risk: they would fail a
142
+ * real tool call on a non-v4 uuid, an enum that drifted, or an omitted optional
143
+ * field, and they also break the pagination merge (which drops `nextCursor`
144
+ * and adds a `pagination` block).
145
+ */
146
+ function relaxJsonSchema(schema) {
147
+ return {
148
+ ...relaxedType(schema),
149
+ ...optionalFields(schema.properties, (properties) => ({
150
+ properties: R.map(properties, relaxJsonSchema),
151
+ required: [],
152
+ additionalProperties: true,
153
+ })),
154
+ ...optionalField("anyOf", schema.anyOf?.map(relaxJsonSchema)),
155
+ ...optionalField("oneOf", schema.oneOf?.map(relaxJsonSchema)),
156
+ };
157
+ }
158
+ const UntrustedProvenance = Schema.Struct({
159
+ untrusted: Schema.Literal(true),
160
+ note: Schema.String,
161
+ });
162
+ /**
163
+ * The `structuredContent` fields for a tool: `{ result }`, plus an optional
164
+ * `provenance` marker for operations that carry lead-authored text. `result` is
165
+ * the operation's success-response body, relaxed by {@link relaxJsonSchema} so
166
+ * real results validate while malformed ones fail loud.
167
+ */
168
+ export function openApiToolOutputFields(input) {
169
+ const result = input.body
170
+ ? schemaFromOpenApi(relaxJsonSchema(input.body))
171
+ : Schema.Unknown;
172
+ return input.untrustedContent
173
+ ? { result, provenance: Schema.optionalKey(UntrustedProvenance) }
174
+ : { result };
175
+ }
@@ -1,3 +1,4 @@
1
+ import * as R from "effect/Record";
1
2
  import { analyticsOperations } from "./groups/analytics.js";
2
3
  import { bookingsOperations } from "./groups/bookings.js";
3
4
  import { broadcastsOperations } from "./groups/broadcasts.js";
@@ -11,6 +12,7 @@ import { campaignsOperations } from "./groups/campaigns.js";
11
12
  // summary) live in `manifest.ts`, so adding a route stays a one-entry edit in
12
13
  // the file its group already owns.
13
14
  import { capabilitiesOperations } from "./groups/capabilities.js";
15
+ import { channelsOperations } from "./groups/channels.js";
14
16
  import { coachOperations } from "./groups/coach.js";
15
17
  import { conversationsOperations } from "./groups/conversations.js";
16
18
  import { insightsOperations } from "./groups/insights.js";
@@ -40,6 +42,7 @@ export const LUCA_OPERATIONS = [
40
42
  ...coachOperations,
41
43
  ...usageOperations,
42
44
  ...safetyOperations,
45
+ ...channelsOperations,
43
46
  ...reportsOperations,
44
47
  ...insightsOperations,
45
48
  ...learningOperations,
@@ -48,3 +51,17 @@ export const LUCA_OPERATIONS = [
48
51
  ...cadencesOperations,
49
52
  ...analyticsOperations,
50
53
  ];
54
+ const OPERATIONS_BY_ID = R.fromEntries(LUCA_OPERATIONS.map((operation) => [operation.id, operation]));
55
+ /**
56
+ * The catalog entry for an id. Callers name ids in code, so a missing entry is
57
+ * a wiring bug rather than a runtime condition, and it throws where the first
58
+ * test that touches the caller will see it.
59
+ */
60
+ export function operationById(id) {
61
+ const operation = OPERATIONS_BY_ID[id];
62
+ if (operation === undefined) {
63
+ // oxlint-disable-next-line effect/avoid-untagged-errors -- a missing catalog entry is a programming error, not a runtime failure
64
+ throw new Error(`Luca operation ${id} is not in the catalog.`);
65
+ }
66
+ return operation;
67
+ }
@@ -24,23 +24,27 @@ export const analyticsOperations = [
24
24
  id: "analytics.callIntelligence",
25
25
  title: "Read call intelligence",
26
26
  description: "Post-call intelligence for a period: show rate, close rate, average rating and rating distribution, call-to-close lag, repeat-call cohort, day-of-week and call-sequence show rates, and surprise patterns, derived from post-call events and coach-recorded outcomes.",
27
- redactedFields: ["surprisePatterns"],
28
27
  }),
29
28
  op({
30
29
  id: "analytics.callIntelligenceCalls",
31
30
  title: "List calls behind call intelligence",
32
31
  description: "Most recent completed calls with a generated summary, newest first: the coach-facing summary, attendance outcome, rating, and lead name.",
33
- redactedFields: ["calls[].leadName", "calls[].summary"],
32
+ // The summary is generated from what the lead said on the call and the
33
+ // lead name comes off their channel profile, so both are text the lead
34
+ // controls. Same reasoning as the `callEvents.summary.*` overrides.
35
+ untrustedContent: true,
34
36
  }),
35
37
  op({
36
38
  id: "analytics.ghostedLeads",
37
39
  title: "List ghosted leads",
38
40
  description: "Ghosted-lead counts, rescue-recovery rate, lost-revenue estimate, and top conversation-killer clusters for a period.",
41
+ // Each top-killer cluster label is free text drawn from lead messages.
42
+ untrustedContent: true,
39
43
  }),
40
44
  op({
41
45
  id: "analytics.trustScore",
42
46
  title: "Read the trust score",
43
- description: "Draft approval, edit, and rejection rates plus auto-send gate state (auto-send flag and any open human-approval window) for a period.",
47
+ description: "Draft approval, edit, and rejection rates plus the auto-send gate flag for a period.",
44
48
  }),
45
49
  op({
46
50
  id: "analytics.leadScore",
@@ -86,7 +90,11 @@ export const analyticsOperations = [
86
90
  id: "analytics.recommendations",
87
91
  title: "Read recommendations",
88
92
  description: "The coach's active prescriptive recommendations for a week (what to change and why), generated weekly from detected insights and deduped by title. An empty list is the honest, expected state when nothing is recommended for the window — never a fabricated action.",
89
- redactedFields: ["recommendations[].body"],
93
+ // The recommender is handed a verbatim lead-message excerpt per
94
+ // conversation-killer cluster, which `packages/agents/src/recommendations.ts`
95
+ // labels UNTRUSTED and tells the model to use "for color", so the body is
96
+ // the one field here that can echo a lead's own words back out.
97
+ untrustedContent: true,
90
98
  }),
91
99
  op({
92
100
  id: "analytics.benchmarkConsent",
@@ -97,7 +105,6 @@ export const analyticsOperations = [
97
105
  id: "analytics.learningInsights",
98
106
  title: "Read what Luca has learned",
99
107
  description: "Read-only rollup of the agent-improvement signals mined from post-call feedback for a period: total/applied/pending signal counts, a per-type breakdown (qualification, objection handling, lead quality, general), and recent example notes. An empty rollup is the honest, expected state until the coach submits call feedback — never fabricated learning.",
100
- redactedFields: ["recentExamples[].note"],
101
108
  }),
102
109
  op({
103
110
  id: "analytics.crmSyncHealth",
@@ -108,15 +115,18 @@ export const analyticsOperations = [
108
115
  id: "analytics.objections",
109
116
  title: "Read objection patterns",
110
117
  description: "Objection-bucket rollup over the review queue's qualifier signals for a period (price, timing, trust, fit, authority, smokescreen): per-bucket occurrence count, distinct leads, handled rate, wins, and up to three of the most frequent verbatim lead quotes. Filter by channel. An empty bucket list is the honest, expected state until objections are raised — never fabricated.",
111
- redactedFields: ["buckets[].topPhrases"],
118
+ // `topPhrases` is lifted verbatim out of the lead's own messages by
119
+ // `objectionRollup` in packages/db, so an objection a lead phrases as an
120
+ // instruction arrives here as one.
121
+ untrustedContent: true,
112
122
  }),
113
123
  op({
114
124
  id: "analytics.objectionDrilldown",
115
125
  title: "Drill into one objection type",
116
126
  description: "Per-conversation drilldown for one objection bucket: one row per matching conversation with the lead's display name, the verbatim objection quote, whether the draft was sent without an edit, and the lead's most recent call outcome. Bounded by a limit with a hasMore truncation flag; an empty list is the honest, expected state for a bucket with no conversations in the window.",
117
- redactedFields: [
118
- "conversations[].leadDisplayName",
119
- "conversations[].phrase",
120
- ],
127
+ // The row-level half of the rollup above: `phrase` is the same verbatim
128
+ // lead quote, and `leadDisplayName` is whatever the lead set on their
129
+ // channel profile.
130
+ untrustedContent: true,
121
131
  }),
122
132
  ];
@@ -4,19 +4,27 @@ export const bookingsOperations = [
4
4
  id: "bookings.list",
5
5
  title: "List bookings",
6
6
  description: "List bookings. Query supports limit, cursor, status, and upcoming.",
7
- redactedFields: ["bookings[].rawPayload"],
7
+ // Each booking carries a cancellation reason the invitee may have written.
8
+ untrustedContent: true,
8
9
  }),
9
10
  op({
10
11
  id: "bookings.create",
11
12
  title: "Book a call",
13
+ // Moves the lead to booked and tells every webhook subscriber. Gated like
14
+ // reschedule and cancel so one approval covers each change to a lead's call.
15
+ confirm: "always",
16
+ // The booking webhook reaches subscribers outside Luca.
17
+ openWorld: true,
12
18
  description: "Create a provider-neutral booking record. Body requires leadId and slotAt.",
13
- redactedFields: ["booking.rawPayload"],
19
+ // Each booking carries a cancellation reason the invitee may have written.
20
+ untrustedContent: true,
14
21
  }),
15
22
  op({
16
23
  id: "bookings.get",
17
24
  title: "Get a booking",
18
25
  description: "Read one booking: its slot, status, provider, and lead.",
19
- redactedFields: ["booking.rawPayload"],
26
+ // Each booking carries a cancellation reason the invitee may have written.
27
+ untrustedContent: true,
20
28
  }),
21
29
  op({
22
30
  id: "bookings.availability",
@@ -41,7 +49,6 @@ export const bookingsOperations = [
41
49
  op({
42
50
  id: "bookings.types.attendees",
43
51
  title: "List a call type's attendees",
44
- redactedFields: ["attendees[].email", "attendees[].name"],
45
52
  description: "List the people invited to every call of this type, on top of the lead.",
46
53
  }),
47
54
  op({
@@ -68,60 +75,76 @@ export const bookingsOperations = [
68
75
  id: "bookings.outcome.get",
69
76
  title: "Get a booking's outcome",
70
77
  description: "Read where the sale stands on a booking, plus the version you send back to correct it.",
71
- redactedFields: ["episode.note"],
72
78
  }),
73
79
  op({
74
80
  id: "bookings.outcome.history",
75
81
  title: "Read a booking's outcome history",
76
82
  description: "Read every outcome ever recorded on a booking, in order. Corrections append rather than overwrite.",
77
- redactedFields: ["episode.note", "events[].note"],
78
83
  }),
79
84
  op({
80
85
  id: "bookings.needsOutcome",
81
86
  title: "List calls with no outcome recorded",
82
87
  description: "List attended calls with no outcome recorded yet. The work list for closing revenue attribution.",
83
- redactedFields: ["bookings[].leadName"],
88
+ // Each row carries the lead's name, which the lead controls.
89
+ untrustedContent: true,
84
90
  }),
85
91
  op({
86
92
  id: "bookings.lifecycleAttention",
87
93
  title: "List bookings needing attention",
88
94
  description: "List bookings where Luca's record and the provider's disagree and a human has to decide.",
89
- redactedFields: ["bookings[].rawPayload"],
95
+ // Each booking carries a cancellation reason the invitee may have written.
96
+ untrustedContent: true,
90
97
  }),
91
98
  op({
92
99
  id: "bookings.brief",
93
100
  title: "Read a pre-call brief",
94
101
  description: "Read the pre-call brief: who the lead is, what they want, and the objections to expect.",
95
102
  untrustedContent: true,
96
- redactedFields: ["brief"],
97
103
  }),
98
104
  op({
99
105
  id: "bookings.update",
100
106
  title: "Reschedule or edit a booking",
107
+ openWorld: true,
108
+ mutatesExisting: true,
109
+ // Moves the call on the provider calendar and re-arms the lead's reminder.
110
+ confirm: "always",
101
111
  description: "Reschedule or update booking status.",
102
- redactedFields: ["booking.rawPayload"],
112
+ // Each booking carries a cancellation reason the invitee may have written.
113
+ untrustedContent: true,
103
114
  }),
104
115
  op({
105
116
  id: "bookings.cancel",
106
117
  title: "Cancel a booking",
118
+ openWorld: true,
119
+ mutatesExisting: true,
120
+ // Cancels a real call on the lead's and the coach's calendars.
121
+ confirm: "always",
107
122
  description: "Cancel a booking.",
108
- redactedFields: ["booking.rawPayload"],
123
+ // Each booking carries a cancellation reason the invitee may have written.
124
+ untrustedContent: true,
109
125
  }),
110
126
  op({
111
127
  id: "bookings.outcome.record",
112
128
  title: "Record a booking's outcome",
129
+ mutatesExisting: true,
113
130
  description: "Record what the call was worth: won, lost with a reason, or still open. To correct an earlier outcome send the expectedVersion you read; a stale version returns 409 with the current one.",
114
- redactedFields: ["episode.note"],
115
131
  }),
116
132
  op({
117
133
  id: "bookings.lifecycleResolution",
118
134
  title: "Resolve a booking's lifecycle flag",
135
+ openWorld: true,
136
+ confirm: "always",
137
+ mutatesExisting: true,
119
138
  description: "Settle one booking whose record disagrees with the provider's: retry the sync, dismiss the duplicate, or keep Luca's version.",
120
- redactedFields: ["booking.rawPayload"],
139
+ // Each booking carries a cancellation reason the invitee may have written.
140
+ untrustedContent: true,
121
141
  }),
122
142
  op({
123
143
  id: "bookings.providerSync.retry",
124
144
  title: "Retry a booking's provider sync",
145
+ openWorld: true,
146
+ confirm: "always",
147
+ mutatesExisting: true,
125
148
  description: "Retry a failed write-back to the booking provider. A permanent failure returns 409 rather than retrying forever.",
126
149
  }),
127
150
  op({
@@ -132,6 +155,7 @@ export const bookingsOperations = [
132
155
  op({
133
156
  id: "bookings.guestLinks.revoke",
134
157
  title: "Revoke a booking's guest links",
158
+ mutatesExisting: true,
135
159
  description: "Kill every outstanding guest link on a booking at once.",
136
160
  }),
137
161
  op({
@@ -142,6 +166,7 @@ export const bookingsOperations = [
142
166
  op({
143
167
  id: "bookings.outcomeReasons.update",
144
168
  title: "Update a loss reason",
169
+ mutatesExisting: true,
145
170
  description: "Rename, reorder, or archive an outcome reason. Archiving hides it from the picker and leaves recorded outcomes alone.",
146
171
  }),
147
172
  op({
@@ -152,6 +177,7 @@ export const bookingsOperations = [
152
177
  op({
153
178
  id: "bookings.types.update",
154
179
  title: "Update a booking type",
180
+ mutatesExisting: true,
155
181
  description: "Update only the fields you send on one booking type. Sending `location` replaces the whole location, because its shape depends on the type.",
156
182
  }),
157
183
  op({
@@ -162,28 +188,30 @@ export const bookingsOperations = [
162
188
  op({
163
189
  id: "bookings.types.schedule.replace",
164
190
  title: "Replace a booking type schedule",
191
+ mutatesExisting: true,
165
192
  description: "Replace a booking type's schedule outright. Send every rule and override you want to keep \u2014 anything left out is dropped, which is also how you clear a day. Read the current schedule first unless you mean to start over.",
166
193
  }),
167
194
  op({
168
195
  id: "bookings.types.attendees.add",
169
196
  title: "Add a booking type attendee",
170
- redactedFields: ["attendees[].email", "attendees[].name"],
171
197
  description: "Add a standing attendee to one booking type, so they are invited to every call booked against it. Returns the full attendee list.",
172
198
  }),
173
199
  op({
174
200
  id: "bookings.types.attendees.remove",
175
201
  title: "Remove a booking type attendee",
176
- redactedFields: ["attendees[].email", "attendees[].name"],
202
+ mutatesExisting: true,
177
203
  description: "Remove a standing attendee from one booking type and get back the ones that are left. Calls already booked keep the invitees they were created with.",
178
204
  }),
179
205
  op({
180
206
  id: "bookings.calendars.select",
181
207
  title: "Select a booking calendar",
208
+ mutatesExisting: true,
182
209
  description: "Add or update a calendar selection, either for conflict checks or as the destination new bookings are written to. The destination has to be writable: a read-only calendar returns 400, and a Google connection needing attention returns 409.",
183
210
  }),
184
211
  op({
185
212
  id: "bookings.calendars.remove",
186
213
  title: "Remove a booking calendar",
214
+ mutatesExisting: true,
187
215
  description: "Stop using a calendar for conflict checks or as the booking destination. The calendar itself is left alone in Google; only Luca's selection changes.",
188
216
  }),
189
217
  ];
@@ -1,4 +1,7 @@
1
1
  import { op } from "../registry.js";
2
+ // Operations that return a broadcast carry its recipient snapshot: each lead's
3
+ // display name and custom fields, which the lead wrote on their own profile.
4
+ // The coach-written message body alone would not need the untrusted frame.
2
5
  export const broadcastsOperations = [
3
6
  op({
4
7
  id: "broadcasts.list",
@@ -8,99 +11,67 @@ export const broadcastsOperations = [
8
11
  op({
9
12
  id: "broadcasts.get",
10
13
  title: "Get a broadcast",
14
+ // Recipient snapshot, see the note above this list.
15
+ untrustedContent: true,
11
16
  description: "Get broadcast detail.",
12
- redactedFields: [
13
- "recipients[].messageBody",
14
- "sampleMessages[].body",
15
- "segmentSnapshot.excludedRecipients[].displayName",
16
- "segmentSnapshot.leads[].customFields",
17
- "segmentSnapshot.leads[].displayName",
18
- ],
19
17
  }),
20
18
  op({
21
19
  id: "broadcasts.create",
22
20
  title: "Create a broadcast",
21
+ // Recipient snapshot, see the note above this list.
22
+ untrustedContent: true,
23
23
  description: "Create a broadcast draft. Body requires campaignId and name.",
24
- redactedFields: [
25
- "recipients[].messageBody",
26
- "sampleMessages[].body",
27
- "segmentSnapshot.excludedRecipients[].displayName",
28
- "segmentSnapshot.leads[].customFields",
29
- "segmentSnapshot.leads[].displayName",
30
- ],
31
24
  }),
32
25
  op({
33
26
  id: "broadcasts.preview",
34
27
  title: "Preview a broadcast",
28
+ mutatesExisting: true,
29
+ // Recipient snapshot, see the note above this list.
30
+ untrustedContent: true,
35
31
  description: "Refresh broadcast recipient and sample-message preview.",
36
- redactedFields: [
37
- "recipients[].messageBody",
38
- "sampleMessages[].body",
39
- "segmentSnapshot.excludedRecipients[].displayName",
40
- "segmentSnapshot.leads[].customFields",
41
- "segmentSnapshot.leads[].displayName",
42
- ],
43
32
  }),
44
33
  op({
45
34
  id: "broadcasts.approve",
46
35
  title: "Approve a broadcast",
36
+ mutatesExisting: true,
37
+ // Recipient snapshot, see the note above this list.
38
+ untrustedContent: true,
47
39
  description: "Approve a safe broadcast draft and snapshot recipients.",
48
- redactedFields: [
49
- "recipients[].messageBody",
50
- "sampleMessages[].body",
51
- "segmentSnapshot.excludedRecipients[].displayName",
52
- "segmentSnapshot.leads[].customFields",
53
- "segmentSnapshot.leads[].displayName",
54
- ],
55
40
  }),
56
41
  op({
57
42
  id: "broadcasts.launch",
58
43
  title: "Launch a broadcast",
59
- confirmRequired: true,
44
+ openWorld: true,
45
+ mutatesExisting: true,
46
+ confirm: "always",
47
+ // Recipient snapshot, see the note above this list.
48
+ untrustedContent: true,
60
49
  description: "Launch an approved broadcast. Sends messages to live leads.",
61
- redactedFields: [
62
- "recipients[].messageBody",
63
- "sampleMessages[].body",
64
- "segmentSnapshot.excludedRecipients[].displayName",
65
- "segmentSnapshot.leads[].customFields",
66
- "segmentSnapshot.leads[].displayName",
67
- ],
68
50
  }),
69
51
  op({
70
52
  id: "broadcasts.pause",
71
53
  title: "Pause a broadcast",
54
+ mutatesExisting: true,
55
+ // Recipient snapshot, see the note above this list.
56
+ untrustedContent: true,
72
57
  description: "Pause a running broadcast with an optional operator reason.",
73
- redactedFields: [
74
- "recipients[].messageBody",
75
- "sampleMessages[].body",
76
- "segmentSnapshot.excludedRecipients[].displayName",
77
- "segmentSnapshot.leads[].customFields",
78
- "segmentSnapshot.leads[].displayName",
79
- ],
80
58
  }),
81
59
  op({
82
60
  id: "broadcasts.retry",
83
61
  title: "Retry a broadcast's failed sends",
84
- confirmRequired: true,
62
+ openWorld: true,
63
+ mutatesExisting: true,
64
+ confirm: "always",
65
+ // Recipient snapshot, see the note above this list.
66
+ untrustedContent: true,
85
67
  description: "Retry failed broadcast recipients after operator review. Re-sends messages to leads.",
86
- redactedFields: [
87
- "recipients[].messageBody",
88
- "sampleMessages[].body",
89
- "segmentSnapshot.excludedRecipients[].displayName",
90
- "segmentSnapshot.leads[].customFields",
91
- "segmentSnapshot.leads[].displayName",
92
- ],
93
68
  }),
94
69
  op({
95
70
  id: "broadcasts.cancel",
96
71
  title: "Cancel a broadcast",
72
+ mutatesExisting: true,
73
+ // Recipient snapshot, see the note above this list.
74
+ untrustedContent: true,
97
75
  description: "Cancel a draft, approved, running, paused, or failed broadcast.",
98
- redactedFields: [
99
- "recipients[].messageBody",
100
- "sampleMessages[].body",
101
- "segmentSnapshot.excludedRecipients[].displayName",
102
- "segmentSnapshot.leads[].customFields",
103
- "segmentSnapshot.leads[].displayName",
104
- ],
105
76
  }),
106
77
  ];
@@ -3,12 +3,14 @@ export const cadencesOperations = [
3
3
  op({
4
4
  id: "cadences.rescueStart",
5
5
  title: "Start a silent-lead rescue",
6
- confirmRequired: true,
6
+ openWorld: true,
7
+ confirm: "always",
7
8
  description: "Enroll silent leads into the multi-day rescue cadence. Starts outbound touches to real leads.",
8
9
  }),
9
10
  op({
10
11
  id: "cadences.pause",
11
12
  title: "Pause a lead's cadence",
13
+ mutatesExisting: true,
12
14
  description: "Pause (cancel) a lead's running rescue cadence with an optional reason.",
13
15
  }),
14
16
  ];