@noodleseed/one 0.187.0 → 0.188.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 (63) hide show
  1. package/dist/commands/solutions-conversations.js +1 -1
  2. package/dist/commands/solutions-conversations.js.map +1 -1
  3. package/dist/commands/solutions-history.d.ts.map +1 -1
  4. package/dist/commands/solutions-history.js +5 -2
  5. package/dist/commands/solutions-history.js.map +1 -1
  6. package/dist/commands/whatsapp-ops.d.ts +1 -1
  7. package/dist/commands/whatsapp-ops.d.ts.map +1 -1
  8. package/dist/commands/whatsapp-ops.js +31 -9
  9. package/dist/commands/whatsapp-ops.js.map +1 -1
  10. package/dist/commands/whatsapp-output.d.ts +1 -1
  11. package/dist/commands/whatsapp-output.d.ts.map +1 -1
  12. package/dist/commands/whatsapp-output.js +8 -6
  13. package/dist/commands/whatsapp-output.js.map +1 -1
  14. package/node_modules/@noodle-borg/agent-kit/dist/generated/example-files.js +1 -1
  15. package/node_modules/@noodle-borg/agent-kit/dist/skill-embedded-assistant-ref.js +3 -1
  16. package/node_modules/@noodle-borg/agent-kit/package.json +1 -1
  17. package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-model-context.js +1 -0
  18. package/node_modules/@noodle-borg/assistant-gateway/dist/channel-coordinator.d.ts +89 -0
  19. package/node_modules/@noodle-borg/assistant-gateway/dist/channel-coordinator.js +20 -1
  20. package/node_modules/@noodle-borg/assistant-gateway/dist/channel-inbox.js +16 -1
  21. package/node_modules/@noodle-borg/assistant-gateway/dist/channel-store.d.ts +1 -1
  22. package/node_modules/@noodle-borg/assistant-gateway/dist/channel-types.d.ts +34 -3
  23. package/node_modules/@noodle-borg/assistant-gateway/dist/channel-types.js +10 -4
  24. package/node_modules/@noodle-borg/authoring/dist/assistant.d.ts +1 -1
  25. package/node_modules/@noodle-borg/authoring/dist/assistant.js +1 -1
  26. package/node_modules/@noodle-borg/cli-catalog/dist/catalog-data-channels.js +26 -0
  27. package/node_modules/@noodle-borg/cli-catalog/dist/catalog-data-solution-conversations.js +1 -1
  28. package/node_modules/@noodle-borg/cli-catalog/dist/catalog-data-solution-history.js +1 -0
  29. package/node_modules/@noodle-borg/compiler/dist/manifest/schema.d.ts +4 -0
  30. package/node_modules/@noodle-borg/compiler/dist/manifest/schema.js +1 -1
  31. package/node_modules/@noodle-borg/compiler/dist/manifest/website-projection.d.ts +1 -0
  32. package/node_modules/@noodle-borg/compiler/dist/manifest/website-projection.js +11 -2
  33. package/node_modules/@noodle-borg/runtime/dist/connector/types.d.ts +1 -1
  34. package/node_modules/@noodle-borg/service/dist/application-history-settings.js +2 -0
  35. package/node_modules/@noodle-borg/service/dist/build-info.js +1 -0
  36. package/node_modules/@noodle-borg/service/dist/channels/composition.js +3 -2
  37. package/node_modules/@noodle-borg/service/dist/channels/messaging-eligibility.js +6 -1
  38. package/node_modules/@noodle-borg/service/dist/channels/reply-bound.js +4 -0
  39. package/node_modules/@noodle-borg/service/dist/channels/runtime.js +38 -10
  40. package/node_modules/@noodle-borg/service/dist/channels/twilio-sms.js +188 -0
  41. package/node_modules/@noodle-borg/service/dist/channels/twilio-webhook.js +16 -0
  42. package/node_modules/@noodle-borg/service/dist/conversation-history/contracts.js +1 -0
  43. package/node_modules/@noodle-borg/service/dist/routes/business-information-channels.js +3 -1
  44. package/node_modules/@noodle-borg/service/dist/routes/service-probes.js +1 -0
  45. package/node_modules/@noodle-borg/service/dist/routes/sms-twilio.js +66 -0
  46. package/node_modules/@noodle-borg/service/dist/routes/whatsapp.js +31 -21
  47. package/node_modules/@noodle-borg/service/dist/service.js +2 -2
  48. package/node_modules/@noodle-borg/service/package.json +1 -1
  49. package/node_modules/@noodle-borg/wire-contracts/dist/application-channels.d.ts +33 -0
  50. package/node_modules/@noodle-borg/wire-contracts/dist/application-channels.js +3 -1
  51. package/node_modules/@noodle-borg/wire-contracts/dist/application-history-settings.d.ts +7 -0
  52. package/node_modules/@noodle-borg/wire-contracts/dist/application-history-settings.js +2 -1
  53. package/node_modules/@noodle-borg/wire-contracts/dist/conversation-history.d.ts +12 -0
  54. package/node_modules/@noodle-borg/wire-contracts/dist/conversation-history.js +1 -1
  55. package/node_modules/@noodle-borg/wire-contracts/dist/index.d.ts +1 -0
  56. package/node_modules/@noodle-borg/wire-contracts/dist/index.js +1 -0
  57. package/node_modules/@noodle-borg/wire-contracts/dist/service-info.d.ts +2 -0
  58. package/node_modules/@noodle-borg/wire-contracts/dist/service-info.js +2 -0
  59. package/node_modules/@noodle-borg/wire-contracts/dist/sms.d.ts +176 -0
  60. package/node_modules/@noodle-borg/wire-contracts/dist/sms.js +41 -0
  61. package/node_modules/@noodle-borg/wire-contracts/dist/whatsapp.d.ts +10 -2
  62. package/node_modules/@noodle-borg/wire-contracts/dist/whatsapp.js +2 -1
  63. package/package.json +2 -2
@@ -29,10 +29,12 @@ export function renderEmbeddedAssistantReference() {
29
29
  '- Verify the boundary',
30
30
  '- Troubleshooting: symptom to diagnosis',
31
31
  '',
32
- '## Public messaging: WhatsApp and Instagram DMs',
32
+ '## Public messaging: WhatsApp, Instagram DMs and SMS',
33
33
  '',
34
34
  'Reuse the same server and public read-only capabilities with `publicMessaging({ channel: "whatsapp", capabilities: [product, siteIdentity] })` in `embeddedAssistant.access`. Import `publicMessaging` from `@noodleseed/one`. This projection is explicit: knowledge, pure public reads, and a `collect` opener listed with its confirmed native-record action. The platform’s natural collection gathers the opener’s fields in conversation, asks the consent question, renders the review verbatim and confirms; a field marked `private: true` never reaches the conversation model or the transcript. Other writes, connector-backed lookups, customer identity and browser widgets without an interaction are not supported. A WhatsApp participant is anonymous; never infer a customer account from its phone number. Declare Instagram direct messages the same way with `publicMessaging({ channel: "instagram", capabilities })`: at most one surface per channel, each with its own capabilities and instructions. The surface answers once an operator connects the professional account and its linked Facebook Page with `noodle channels instagram configure`, `webhook configure`, `doctor` and `enable` on a service advertising `features.instagram: 1`; replies are plain text of at most 1000 bytes, and an Instagram participant is anonymous too.',
35
35
  '',
36
+ 'For SMS, declare `publicMessaging({ channel: "sms", capabilities: [product, siteIdentity] })`. The first Twilio profile supports knowledge and pure public reads only; collection, writes, widgets and customer identity are unavailable. An operator uses `noodle channels sms configure`, `webhook configure`, `doctor` and `enable` with an SMS-capable Twilio number, account/number SIDs and an Auth Token secret reference on a service advertising `features.sms: 1`. Use concise plain text (aim for 300 characters; the platform bounds replies to 600). STOP/START/HELP are provider controls, never model prompts. Registration, number rental and per-segment charges remain with the Twilio account owner; technical readiness does not prove carrier delivery.',
37
+ '',
36
38
  'Operator setup uses `noodle channels whatsapp configure`, managed secret references, `webhook configure`, `doctor`, and `enable`. Start paused. Only a service advertising `features.whatsapp: 1`, durable storage, a running worker, a verified cost bound and matching provider webhook can enable. Use `noodle channels whatsapp --help` for the complete operator command catalog. Keep real keys in `noodle secrets`, never in source, flags or messages. ChatGPT and Claude continue to use the same app MCP endpoint with their own host configuration.',
37
39
  '',
38
40
  '## Collect interactions',
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noodle-borg/agent-kit",
3
- "version": "0.111.0",
3
+ "version": "0.112.0",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "engines": {
@@ -107,6 +107,7 @@ function invocationContextMessages(context) {
107
107
  }
108
108
  /** What each messaging app renders; Instagram caps a reply at 1000 bytes, so the model aims well under. */
109
109
  const MESSAGING_PROFILES = {
110
+ sms: 'SMS surface: concise plain text, no Markdown, at most 300 characters. Use bare URLs and answer one question at a time.',
110
111
  whatsapp: 'WhatsApp surface: concise plain text, at most 3500 characters.',
111
112
  instagram: 'Instagram DM surface: plain text only, no Markdown or lists, at most 600 characters, bare URLs.',
112
113
  };
@@ -36,6 +36,27 @@ export declare class ChannelCoordinator {
36
36
  actor: string;
37
37
  readyRevision?: number | undefined;
38
38
  readyAt?: number | undefined;
39
+ } | {
40
+ channel: "sms";
41
+ provider: "twilio";
42
+ accountSid: string;
43
+ phoneNumberSid: string;
44
+ phoneNumber: string;
45
+ id: string;
46
+ tenant: TenantRef;
47
+ apiKeySecret: string;
48
+ deploymentId: string;
49
+ capabilities: readonly import("./channel-types.js").ChannelCapability[];
50
+ supportEmail: string;
51
+ revision: number;
52
+ generation: number;
53
+ state: "paused" | "enabled" | "disconnected";
54
+ limits: import("./channel-types.js").ChannelLimits;
55
+ createdAt: number;
56
+ updatedAt: number;
57
+ actor: string;
58
+ readyRevision?: number | undefined;
59
+ readyAt?: number | undefined;
39
60
  } | {
40
61
  provider: import("./channel-types.js").ChannelProvider;
41
62
  phoneNumberId: string;
@@ -84,6 +105,27 @@ export declare class ChannelCoordinator {
84
105
  actor: string;
85
106
  readyRevision?: number | undefined;
86
107
  readyAt?: number | undefined;
108
+ } | {
109
+ channel: "sms";
110
+ provider: "twilio";
111
+ accountSid: string;
112
+ phoneNumberSid: string;
113
+ phoneNumber: string;
114
+ id: string;
115
+ tenant: TenantRef;
116
+ apiKeySecret: string;
117
+ deploymentId: string;
118
+ capabilities: readonly import("./channel-types.js").ChannelCapability[];
119
+ supportEmail: string;
120
+ revision: number;
121
+ generation: number;
122
+ state: "paused" | "enabled" | "disconnected";
123
+ limits: import("./channel-types.js").ChannelLimits;
124
+ createdAt: number;
125
+ updatedAt: number;
126
+ actor: string;
127
+ readyRevision?: number | undefined;
128
+ readyAt?: number | undefined;
87
129
  } | {
88
130
  provider: import("./channel-types.js").ChannelProvider;
89
131
  phoneNumberId: string;
@@ -127,6 +169,27 @@ export declare class ChannelCoordinator {
127
169
  actor: string;
128
170
  readyRevision?: number | undefined;
129
171
  readyAt?: number | undefined;
172
+ } | {
173
+ channel: "sms";
174
+ provider: "twilio";
175
+ accountSid: string;
176
+ phoneNumberSid: string;
177
+ phoneNumber: string;
178
+ id: string;
179
+ tenant: TenantRef;
180
+ apiKeySecret: string;
181
+ deploymentId: string;
182
+ capabilities: readonly import("./channel-types.js").ChannelCapability[];
183
+ supportEmail: string;
184
+ revision: number;
185
+ generation: number;
186
+ state: "paused" | "enabled" | "disconnected";
187
+ limits: import("./channel-types.js").ChannelLimits;
188
+ createdAt: number;
189
+ updatedAt: number;
190
+ actor: string;
191
+ readyRevision?: number | undefined;
192
+ readyAt?: number | undefined;
130
193
  } | {
131
194
  provider: import("./channel-types.js").ChannelProvider;
132
195
  phoneNumberId: string;
@@ -151,6 +214,11 @@ export declare class ChannelCoordinator {
151
214
  block(id: string, participantId: string, actor: string, key: string, until: number | null): Promise<void>;
152
215
  unblock(id: string, participantId: string, actor: string, key: string): Promise<void>;
153
216
  blocks(id: string, after?: string): Promise<ChannelBlock[]>;
217
+ optouts(id: string, after?: string): Promise<{
218
+ at: number;
219
+ blocked: boolean;
220
+ participantId: string;
221
+ }[]>;
154
222
  events(id: string, after?: string): Promise<ChannelEvent[]>;
155
223
  forget(id: string, participantId: string, actor: string, key: string): Promise<void>;
156
224
  updateLimits(id: string, input: ChannelLimitsInput, actor: string, key: string, revision: number): Promise<{
@@ -173,6 +241,27 @@ export declare class ChannelCoordinator {
173
241
  actor: string;
174
242
  readyRevision?: number | undefined;
175
243
  readyAt?: number | undefined;
244
+ } | {
245
+ channel: "sms";
246
+ provider: "twilio";
247
+ accountSid: string;
248
+ phoneNumberSid: string;
249
+ phoneNumber: string;
250
+ id: string;
251
+ tenant: TenantRef;
252
+ apiKeySecret: string;
253
+ deploymentId: string;
254
+ capabilities: readonly import("./channel-types.js").ChannelCapability[];
255
+ supportEmail: string;
256
+ revision: number;
257
+ generation: number;
258
+ state: "paused" | "enabled" | "disconnected";
259
+ limits: import("./channel-types.js").ChannelLimits;
260
+ createdAt: number;
261
+ updatedAt: number;
262
+ actor: string;
263
+ readyRevision?: number | undefined;
264
+ readyAt?: number | undefined;
176
265
  } | {
177
266
  provider: import("./channel-types.js").ChannelProvider;
178
267
  phoneNumberId: string;
@@ -11,7 +11,8 @@ export async function requireChannel(tx, id) {
11
11
  }
12
12
  export async function channelBlocked(tx, id, participant, now) {
13
13
  const block = await channelValue(tx, id, `block:${participant}`);
14
- return !!block && (block.until === null || block.until > now);
14
+ const optout = await channelValue(tx, id, `optout:${participant}`);
15
+ return optout?.blocked === true || (!!block && (block.until === null || block.until > now));
15
16
  }
16
17
  export async function requireChannelLive(tx, id) {
17
18
  const binding = await requireChannel(tx, id);
@@ -38,6 +39,8 @@ async function mutation(tx, id, key, input, now, work) {
38
39
  }
39
40
  /** Validates a channel's provider fields and names the asset its binding will own. */
40
41
  function configuredAsset(input) {
42
+ if (input.channel === 'sms')
43
+ return channelAssetKey('sms', input.phoneNumber);
41
44
  if (input.channel === 'instagram')
42
45
  return channelAssetKey('instagram', input.instagramAccountId);
43
46
  const meta = input.provider === 'meta';
@@ -47,6 +50,8 @@ function configuredAsset(input) {
47
50
  return channelAssetKey('whatsapp', input.phoneNumberId);
48
51
  }
49
52
  function configuredBinding(input, id, state) {
53
+ if (input.channel === 'sms')
54
+ return { ...input, id, provider: 'twilio', ...state };
50
55
  if (input.channel === 'instagram')
51
56
  return { ...input, id, provider: 'meta', ...state };
52
57
  const { provider, channel: _whatsapp, ...configured } = input;
@@ -216,6 +221,20 @@ export class ChannelCoordinator {
216
221
  })).map((row) => row.value);
217
222
  });
218
223
  }
224
+ async optouts(id, after) {
225
+ return this.store.transaction([id], async (tx) => {
226
+ await requireChannel(tx, id);
227
+ const rows = await tx.list(id, {
228
+ kind: 'optout',
229
+ limit: 100,
230
+ ...(after ? { after: `optout:${after}` } : {}),
231
+ });
232
+ return rows.map((row) => ({
233
+ participantId: row.id.slice('optout:'.length),
234
+ ...row.value,
235
+ }));
236
+ });
237
+ }
219
238
  async events(id, after) {
220
239
  return this.store.transaction([id], async (tx) => {
221
240
  await requireChannel(tx, id);
@@ -63,7 +63,22 @@ export async function receiveChannelMessages(store, id, messages, now, retention
63
63
  if ((await channelCounter(tx, id, 'ingress', now, CHANNEL_DAY_MS)) >= 10_000)
64
64
  throw new ChannelError('ingress_capacity');
65
65
  const participantId = channelParticipantId(binding, message.address);
66
- const code = await refusal(tx, binding, message, participantId, pending, now);
66
+ let code = await refusal(tx, binding, message, participantId, pending, now);
67
+ if (binding.channel === 'sms' &&
68
+ message.control &&
69
+ message.eventAt >= now - CHANNEL_RETENTION_MS &&
70
+ message.eventAt <= now + 60_000) {
71
+ if (message.control !== 'help') {
72
+ const key = `optout:${participantId}`;
73
+ const prior = await channelValue(tx, id, key);
74
+ // Older callbacks cannot undo a newer preference; STOP wins equal-second timestamps.
75
+ if (!prior ||
76
+ message.eventAt > prior.at ||
77
+ (message.eventAt === prior.at && message.control === 'stop'))
78
+ await tx.put(id, channelRow(key, 'optout', { at: message.eventAt, blocked: message.control === 'stop' }, now));
79
+ }
80
+ code = `provider_${message.control}`;
81
+ }
67
82
  const sequence = ((await channelValue(tx, id, 'sequence')) ?? 0) + 1;
68
83
  await tx.put(id, channelRow('sequence', 'counter', sequence, now));
69
84
  const eventId = `e_${String(sequence).padStart(20, '0')}`;
@@ -1,7 +1,7 @@
1
1
  /** Private channel journal. Each binding is a transaction/lease authority, not a browser session. */
2
2
  export interface ChannelRow {
3
3
  readonly id: string;
4
- readonly kind: 'binding' | 'asset' | 'participant' | 'event' | 'counter' | 'spend' | 'block' | 'mutation' | 'interaction';
4
+ readonly kind: 'binding' | 'asset' | 'participant' | 'event' | 'counter' | 'spend' | 'block' | 'optout' | 'mutation' | 'interaction';
5
5
  readonly state?: string;
6
6
  readonly updatedAt: number;
7
7
  readonly expiresAt?: number;
@@ -28,7 +28,7 @@ export type ChannelCapability = {
28
28
  readonly name: string;
29
29
  };
30
30
  /** One binding per channel per environment; each channel owns its own provider asset. */
31
- export declare const CHANNEL_KINDS: readonly ["whatsapp", "instagram"];
31
+ export declare const CHANNEL_KINDS: readonly ["whatsapp", "instagram", "sms"];
32
32
  export type ChannelKind = (typeof CHANNEL_KINDS)[number];
33
33
  export interface ChannelBindingBase {
34
34
  readonly id: string;
@@ -68,7 +68,14 @@ export interface InstagramBinding extends ChannelBindingBase {
68
68
  readonly instagramAccountId: string;
69
69
  readonly pageId: string;
70
70
  }
71
- export type ChannelBinding = WhatsAppBinding | InstagramBinding;
71
+ export interface SmsBinding extends ChannelBindingBase {
72
+ readonly channel: 'sms';
73
+ readonly provider: 'twilio';
74
+ readonly accountSid: string;
75
+ readonly phoneNumberSid: string;
76
+ readonly phoneNumber: string;
77
+ }
78
+ export type ChannelBinding = WhatsAppBinding | InstagramBinding | SmsBinding;
72
79
  type ChannelConfigureBase = Pick<ChannelBindingBase, 'tenant' | 'apiKeySecret' | 'deploymentId' | 'capabilities' | 'supportEmail'> & {
73
80
  readonly limits?: ChannelLimitsInput | undefined;
74
81
  };
@@ -79,7 +86,8 @@ export type WhatsAppConfigure = ChannelConfigureBase & Pick<WhatsAppBinding, 'ph
79
86
  readonly provider?: ChannelProvider | undefined;
80
87
  };
81
88
  export type InstagramConfigure = ChannelConfigureBase & Pick<InstagramBinding, 'channel' | 'instagramAccountId' | 'pageId'>;
82
- export type ChannelConfigure = WhatsAppConfigure | InstagramConfigure;
89
+ export type SmsConfigure = ChannelConfigureBase & Pick<SmsBinding, 'channel' | 'accountSid' | 'phoneNumberSid' | 'phoneNumber'>;
90
+ export type ChannelConfigure = WhatsAppConfigure | InstagramConfigure | SmsConfigure;
83
91
  export interface ChannelAddress {
84
92
  readonly kind: 'phone' | 'opaque';
85
93
  readonly value: string;
@@ -130,6 +138,8 @@ export interface ChannelEvent {
130
138
  readonly code?: string;
131
139
  }
132
140
  export interface ChannelInbound {
141
+ /** Authenticated provider opt-out control, never model input. */
142
+ readonly control?: 'stop' | 'start' | 'help';
133
143
  readonly providerId: string;
134
144
  readonly address: ChannelAddress;
135
145
  readonly eventAt: number;
@@ -179,6 +189,27 @@ export declare function publicChannelBinding(binding: ChannelBinding): {
179
189
  actor: string;
180
190
  readyRevision?: number | undefined;
181
191
  readyAt?: number | undefined;
192
+ } | {
193
+ channel: "sms";
194
+ provider: "twilio";
195
+ accountSid: string;
196
+ phoneNumberSid: string;
197
+ phoneNumber: string;
198
+ id: string;
199
+ tenant: TenantRef;
200
+ apiKeySecret: string;
201
+ deploymentId: string;
202
+ capabilities: readonly ChannelCapability[];
203
+ supportEmail: string;
204
+ revision: number;
205
+ generation: number;
206
+ state: "paused" | "enabled" | "disconnected";
207
+ limits: ChannelLimits;
208
+ createdAt: number;
209
+ updatedAt: number;
210
+ actor: string;
211
+ readyRevision?: number | undefined;
212
+ readyAt?: number | undefined;
182
213
  } | {
183
214
  provider: ChannelProvider;
184
215
  phoneNumberId: string;
@@ -15,7 +15,7 @@ export const CHANNEL_DEFAULT_LIMITS = Object.freeze({
15
15
  dailyMicroUsd: 20_000_000,
16
16
  });
17
17
  /** One binding per channel per environment; each channel owns its own provider asset. */
18
- export const CHANNEL_KINDS = ['whatsapp', 'instagram'];
18
+ export const CHANNEL_KINDS = ['whatsapp', 'instagram', 'sms'];
19
19
  export class ChannelError extends Error {
20
20
  code;
21
21
  constructor(code) {
@@ -39,7 +39,11 @@ export function channelAssetKey(channel, assetId) {
39
39
  : `asset:${channel}:${channelDigest(assetId)}`;
40
40
  }
41
41
  export function channelBindingAsset(binding) {
42
- return channelAssetKey(binding.channel, binding.channel === 'whatsapp' ? binding.phoneNumberId : binding.instagramAccountId);
42
+ return channelAssetKey(binding.channel, binding.channel === 'sms'
43
+ ? binding.phoneNumber
44
+ : binding.channel === 'whatsapp'
45
+ ? binding.phoneNumberId
46
+ : binding.instagramAccountId);
43
47
  }
44
48
  export function channelParticipantId(binding, address) {
45
49
  return `p_${createHmac('sha256', Buffer.from(binding.indexKey, 'base64'))
@@ -80,7 +84,7 @@ export async function writeChannelEvent(tx, scope, event, now) {
80
84
  }
81
85
  /** WhatsApp rows are stored without `channel`, byte-identical to rows written before Instagram. */
82
86
  function storedChannelBinding(binding) {
83
- if (binding.channel === 'instagram')
87
+ if (binding.channel !== 'whatsapp')
84
88
  return binding;
85
89
  const { channel: _whatsapp, ...stored } = binding;
86
90
  return stored;
@@ -91,7 +95,9 @@ export function channelBindingRow(binding, now) {
91
95
  /** Rows written before Instagram carry no `channel`; they are WhatsApp. Nothing is written back. */
92
96
  export function readChannelBinding(stored) {
93
97
  const binding = stored;
94
- return binding.channel === 'instagram' ? binding : { ...binding, channel: 'whatsapp' };
98
+ return binding.channel === 'instagram' || binding.channel === 'sms'
99
+ ? binding
100
+ : { ...binding, channel: 'whatsapp' };
95
101
  }
96
102
  export function publicChannelBinding(binding) {
97
103
  const { indexKey: _privateIndexKey, credentialDigest: _credentialDigest, ...visible } = storedChannelBinding(binding);
@@ -224,7 +224,7 @@ export interface PublicWebsiteAccess {
224
224
  }
225
225
  export interface PublicMessagingInput {
226
226
  /** One surface per channel; the operator binds each channel's account separately. */
227
- readonly channel: 'whatsapp' | 'instagram';
227
+ readonly channel: 'whatsapp' | 'instagram' | 'sms';
228
228
  readonly capabilities: readonly CapabilityRef[];
229
229
  readonly instructions?: string;
230
230
  /** @see AuthenticatedWebsiteInput.history */
@@ -9,7 +9,7 @@ export function publicMessaging(input) {
9
9
  throw new Error(`publicMessaging does not accept ${key}`);
10
10
  }
11
11
  }
12
- if (input.channel !== 'whatsapp' && input.channel !== 'instagram')
12
+ if (input.channel !== 'whatsapp' && input.channel !== 'instagram' && input.channel !== 'sms')
13
13
  throw new Error('Unsupported public messaging channel');
14
14
  return {
15
15
  kind: 'messaging',
@@ -86,6 +86,32 @@ export const CHANNELS_COMMAND = {
86
86
  arguments: [],
87
87
  flags: [],
88
88
  subcommands: [
89
+ group('sms', 'Configure a Twilio SMS number and control two-way text messaging.', operations(leaf('configure', 'Save a paused SMS binding using a managed Twilio Auth Token reference.', [
90
+ revision,
91
+ ...mutation,
92
+ flag('account-sid', 'Twilio Account SID (AC...).'),
93
+ flag('phone-number-sid', 'Twilio IncomingPhoneNumber SID (PN...).'),
94
+ flag('phone-number', 'Business sending number in E.164 format.'),
95
+ flag('api-key-secret', 'Managed secret name for the Twilio account Auth Token.'),
96
+ flag('capabilities', 'Comma-separated knowledge:name or tool:name selections.'),
97
+ flag('support-email', 'Contact shown when an answer cannot be completed.'),
98
+ ...limits,
99
+ ]), [
100
+ leaf('block', 'Block locally; Twilio opt-outs remain separately enforced.', [
101
+ participant,
102
+ blockScope,
103
+ flag('phone-stdin', 'Read a phone number securely from standard input.', 'boolean'),
104
+ flag('until', 'Expiry as an ISO timestamp.'),
105
+ flag('indefinite', 'Retain this block until removed.', 'boolean'),
106
+ ...mutation,
107
+ ]),
108
+ leaf('unblock', 'Remove a local block; does not override a recipient opt-out.', [
109
+ participant,
110
+ blockScope,
111
+ flag('phone-stdin', 'Supply the number if retained context expired.', 'boolean'),
112
+ ...mutation,
113
+ ]),
114
+ ])),
89
115
  group('whatsapp', 'Configure a 360dialog or Meta Cloud API number, test readiness, and control public messaging.', operations(leaf('configure', 'Save a paused binding using managed secret references.', [
90
116
  revision,
91
117
  ...mutation,
@@ -31,7 +31,7 @@ const paging = (maximum) => [
31
31
  type: 'string',
32
32
  value: '<channel>',
33
33
  summary: 'Only conversations from this channel.',
34
- constraints: { choices: ['website', 'whatsapp', 'instagram'] },
34
+ constraints: { choices: ['website', 'whatsapp', 'instagram', 'sms'] },
35
35
  },
36
36
  {
37
37
  ...OPTIONAL_FLAG,
@@ -59,6 +59,7 @@ export const SOLUTION_HISTORY = {
59
59
  channel('signed-in-customers', 'Record signed-in customers.'),
60
60
  channel('whatsapp', 'Record WhatsApp conversations.'),
61
61
  channel('instagram', 'Record Instagram DM conversations.'),
62
+ channel('sms', 'Record SMS conversations.'),
62
63
  {
63
64
  ...OPTIONAL_FLAG,
64
65
  required: true,
@@ -364,6 +364,7 @@ export declare const manifestV1Schema: z.ZodObject<{
364
364
  channel: z.ZodEnum<{
365
365
  whatsapp: "whatsapp";
366
366
  instagram: "instagram";
367
+ sms: "sms";
367
368
  }>;
368
369
  mode: z.ZodLiteral<"public">;
369
370
  capabilities: z.ZodArray<z.ZodObject<{
@@ -874,6 +875,7 @@ export declare const manifestV2Schema: z.ZodObject<{
874
875
  channel: z.ZodEnum<{
875
876
  whatsapp: "whatsapp";
876
877
  instagram: "instagram";
878
+ sms: "sms";
877
879
  }>;
878
880
  mode: z.ZodLiteral<"public">;
879
881
  capabilities: z.ZodArray<z.ZodObject<{
@@ -1679,6 +1681,7 @@ export declare const manifestSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
1679
1681
  channel: z.ZodEnum<{
1680
1682
  whatsapp: "whatsapp";
1681
1683
  instagram: "instagram";
1684
+ sms: "sms";
1682
1685
  }>;
1683
1686
  mode: z.ZodLiteral<"public">;
1684
1687
  capabilities: z.ZodArray<z.ZodObject<{
@@ -2188,6 +2191,7 @@ export declare const manifestSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
2188
2191
  channel: z.ZodEnum<{
2189
2192
  whatsapp: "whatsapp";
2190
2193
  instagram: "instagram";
2194
+ sms: "sms";
2191
2195
  }>;
2192
2196
  mode: z.ZodLiteral<"public">;
2193
2197
  capabilities: z.ZodArray<z.ZodObject<{
@@ -199,7 +199,7 @@ const embeddedAssistantSchema = z
199
199
  z
200
200
  .object({
201
201
  kind: z.literal('messaging'),
202
- channel: z.enum(['whatsapp', 'instagram']),
202
+ channel: z.enum(['whatsapp', 'instagram', 'sms']),
203
203
  mode: z.literal('public'),
204
204
  capabilities: z.array(assistantCapabilitySchema),
205
205
  instructions: z.string().trim().min(1).max(MAX_SERVER_INSTRUCTIONS).optional(),
@@ -19,6 +19,7 @@ import type { CompileError } from '../errors.js';
19
19
  */
20
20
  interface AssistantSurfaceInput {
21
21
  readonly kind?: 'website' | 'messaging' | undefined;
22
+ readonly channel?: string | undefined;
22
23
  readonly mode: 'authenticated' | 'public' | 'mixed';
23
24
  readonly capabilities?: readonly {
24
25
  readonly kind: string;
@@ -55,7 +55,7 @@ export function validateWebsiteProjection(assistant, surfaces) {
55
55
  const path = `tools.${tool.name}`;
56
56
  if (surface.kind === 'messaging') {
57
57
  const selected = new Set(surface.capabilities?.flatMap((entry) => (entry.kind === 'tool' ? [entry.name] : [])));
58
- const requirement = messagingRequirement(tool, selected, surfaces);
58
+ const requirement = messagingRequirement(tool, selected, surfaces, surface.channel);
59
59
  if (requirement !== undefined)
60
60
  errors.push({ ...requirement, path });
61
61
  }
@@ -87,9 +87,18 @@ export function validateWebsiteProjection(assistant, surfaces) {
87
87
  * opener may carry a browser widget (the website keeps its React view) because the profile renders
88
88
  * the same block as natural collection. Anything else names the requirement it lacks.
89
89
  */
90
- function messagingRequirement(tool, selected, surfaces) {
90
+ function messagingRequirement(tool, selected, surfaces, channel) {
91
91
  const operations = operationRefs(tool.fulfilment);
92
92
  const interaction = surfaces.interactions?.[tool.name];
93
+ if (channel === 'sms' &&
94
+ (interaction !== undefined ||
95
+ operations.length > 0 ||
96
+ tool.annotations?.readOnlyHint !== true ||
97
+ tool._meta?.ui !== undefined))
98
+ return {
99
+ code: 'channel_requirement_unsupported',
100
+ message: `"${tool.name}" requires an interaction not supported by the SMS question-answering profile`,
101
+ };
93
102
  if (interaction !== undefined && !selected.has(interaction.action)) {
94
103
  return {
95
104
  code: 'channel_dependency_missing',
@@ -92,7 +92,7 @@ export interface PublicAdmissionContext {
92
92
  readonly messaging?: string;
93
93
  readonly provenance?: {
94
94
  readonly kind: 'messaging';
95
- readonly channel: 'whatsapp' | 'instagram';
95
+ readonly channel: 'whatsapp' | 'instagram' | 'sms';
96
96
  };
97
97
  }
98
98
  /** A single connector-operation invocation, fully resolved and credentialed by the runtime. */
@@ -5,6 +5,7 @@ const WIRE_SOURCES = {
5
5
  signedInCustomers: 'signed_in_customers',
6
6
  whatsapp: 'whatsapp',
7
7
  instagram: 'instagram',
8
+ sms: 'sms',
8
9
  };
9
10
  /**
10
11
  * The surfaces the served application declared `history: false` on (ADR 0241 decision 11), so staff
@@ -58,6 +59,7 @@ export function historySettingsProjection(setting, allowance, revision, canEdit)
58
59
  signedInCustomers: setting.sources.signed_in_customers,
59
60
  whatsapp: setting.sources.whatsapp,
60
61
  instagram: setting.sources.instagram,
62
+ sms: setting.sources.sms,
61
63
  },
62
64
  },
63
65
  };
@@ -7,6 +7,7 @@ export function serviceInfoPayload(buildInfo, developerMcp, channels = {}) {
7
7
  features: {
8
8
  mixedCustomerAuth: MIXED_CUSTOMER_AUTH_FEATURE_VERSION,
9
9
  ...(channels.whatsapp ? { whatsapp: 1 } : {}),
10
+ ...(channels.sms ? { sms: 1 } : {}),
10
11
  ...(channels.instagram ? { instagram: 1 } : {}),
11
12
  },
12
13
  ...(developerMcp
@@ -15,10 +15,11 @@ export async function createPostgresWhatsApp(pool, secretBox, options) {
15
15
  };
16
16
  }
17
17
  export function createWhatsAppRuntime(options, deps, history) {
18
- if (!options)
18
+ if (!options.whatsapp)
19
19
  return undefined;
20
20
  return new WhatsAppRuntime({
21
- ...options,
21
+ ...options.whatsapp,
22
+ publicBaseUrl: options.publicBaseUrl,
22
23
  ...(history.installations ? { installations: history.installations } : {}),
23
24
  ...(history.allowance
24
25
  ? {
@@ -6,7 +6,7 @@ import { isNativeRecordsOperation, } from '@noodle-borg/compiler';
6
6
  * its confirmed native-record action selected beside it; everything else names, as a channel error
7
7
  * code, the requirement the messaging profile lacks.
8
8
  */
9
- export function messagingProjectionIneligibility(artifact) {
9
+ export function messagingProjectionIneligibility(artifact, channel = 'whatsapp') {
10
10
  const interactions = artifact.toolInteractions ?? {};
11
11
  const present = new Set(artifact.tools.map((tool) => tool.name));
12
12
  const actions = new Set(Object.entries(interactions)
@@ -14,6 +14,11 @@ export function messagingProjectionIneligibility(artifact) {
14
14
  .map(([, interaction]) => interaction.action));
15
15
  for (const tool of artifact.tools) {
16
16
  const interaction = interactions[tool.name];
17
+ if (channel === 'sms' &&
18
+ (interaction !== undefined ||
19
+ tool.annotations?.readOnlyHint !== true ||
20
+ tool._meta?.ui !== undefined))
21
+ return 'messaging_action_unsupported';
17
22
  if (interaction !== undefined && !present.has(interaction.action))
18
23
  return 'channel_dependency_missing';
19
24
  const operations = operationRefs(tool.fulfilment);
@@ -3,6 +3,10 @@ const EXPAND = '\n\nAsk me to expand on any part.';
3
3
  const INSTAGRAM_BYTES = 1000;
4
4
  /** One reply within its channel's limit, cut on a character boundary and inviting a follow-up. */
5
5
  export function boundReply(channel, answer) {
6
+ if (channel === 'sms')
7
+ return answer.length <= 600
8
+ ? answer
9
+ : `${answer.slice(0, 530).replace(/[\uD800-\uDBFF]$/u, '')}${EXPAND}\nReply STOP to unsubscribe.`;
6
10
  if (channel === 'whatsapp')
7
11
  return answer.length <= 3500
8
12
  ? answer