experimental-ash 0.13.0 → 0.14.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # experimental-ash
2
2
 
3
+ ## 0.14.0
4
+
5
+ ### Minor Changes
6
+
7
+ - b87f0fb: Add a new twilio channel to manage incoming sms and voice messages
8
+
3
9
  ## 0.13.0
4
10
 
5
11
  ### Minor Changes
@@ -230,6 +230,29 @@ dispatch for app mentions, direct messages, and interactions.
230
230
  For a Slack app backed by Vercel Connect, see [Slack channel setup](./slack.md) to create the Connect client
231
231
  and channel file.
232
232
 
233
+ ## Twilio Channels
234
+
235
+ Twilio channels are authored with `twilioChannel()`:
236
+
237
+ ```ts
238
+ import { twilioChannel } from "experimental-ash/channels/twilio";
239
+
240
+ export default twilioChannel({
241
+ allowFrom: "+15551234567",
242
+ });
243
+ ```
244
+
245
+ The channel verifies `X-Twilio-Signature` itself, accepts inbound SMS at
246
+ `/ash/v1/twilio/messages`, answers phone calls at `/ash/v1/twilio/voice`, and dispatches speech
247
+ transcripts posted to `/ash/v1/twilio/voice/transcription`. The raw continuation token is the
248
+ caller/sender phone number plus the Twilio receiver (`From:To`), so texts and call transcripts for
249
+ the same phone-number pair resume the same Ash session without collapsing conversations that use
250
+ different Twilio numbers. `allowFrom` accepts a single phone number, a list, or a zero-argument
251
+ resolver when the allowed phone numbers come from dynamic state. Use `allowFrom: "*"` only when the
252
+ `onText` and `onVoice` hooks perform their own incoming-number checks.
253
+
254
+ See [Twilio channel setup](./twilio.md) for webhook URLs, environment variables, and overrides.
255
+
233
256
  ## File Uploads
234
257
 
235
258
  `send()` accepts `string | UserContent`. To include file attachments,
@@ -0,0 +1,179 @@
1
+ ---
2
+ title: "Twilio channel setup"
3
+ description: "Create a Twilio-backed Ash channel for SMS and speech-transcribed phone calls."
4
+ ---
5
+
6
+ # Twilio Channel Setup
7
+
8
+ The Twilio channel accepts inbound SMS webhooks and inbound voice calls. Voice calls are answered
9
+ with TwiML `<Gather input="speech">`; the Twilio speech transcript is then delivered to the same
10
+ Ash session model as SMS. The raw continuation token is the caller/sender phone number plus the
11
+ Twilio receiver: `From:To`.
12
+
13
+ ## Add The Channel
14
+
15
+ Create `agent/channels/twilio.ts`:
16
+
17
+ ```ts
18
+ import { twilioChannel } from "experimental-ash/channels/twilio";
19
+
20
+ export default twilioChannel({
21
+ allowFrom: "+15551234567",
22
+ messaging: {
23
+ from: "+15557654321",
24
+ },
25
+ });
26
+ ```
27
+
28
+ Set the Twilio credentials Ash needs to verify incoming webhooks and send default SMS replies:
29
+
30
+ ```bash
31
+ TWILIO_ACCOUNT_SID=AC...
32
+ TWILIO_AUTH_TOKEN=...
33
+ ```
34
+
35
+ You can also provide the same values in channel config:
36
+
37
+ ```ts
38
+ export default twilioChannel({
39
+ allowFrom: "+15551234567",
40
+ credentials: {
41
+ accountSid: "AC...",
42
+ authToken: "...",
43
+ },
44
+ messaging: {
45
+ from: "+15557654321",
46
+ },
47
+ });
48
+ ```
49
+
50
+ `TWILIO_AUTH_TOKEN` (or `credentials.authToken`) is required for inbound webhook signature
51
+ verification. `TWILIO_ACCOUNT_SID` and the auth token are required when the default
52
+ `"message.completed"` handler sends replies through Twilio's Messages API. Inbound SMS replies can
53
+ use the webhook's `To` number as the outbound sender; proactive sessions and custom deployments
54
+ should set `messaging.from` or `messaging.messagingServiceSid` so Ash knows which Twilio sender to
55
+ use.
56
+
57
+ By default, the channel mounts:
58
+
59
+ - `POST /ash/v1/twilio/messages` for Messaging webhooks
60
+ - `POST /ash/v1/twilio/voice` for inbound call webhooks
61
+ - `POST /ash/v1/twilio/voice/transcription` for speech transcript callbacks
62
+
63
+ Point your Twilio phone number's Messaging webhook at `/messages` and Voice webhook at `/voice`.
64
+ Use the exact public URL Twilio will call, including the same route prefix you configure in Ash.
65
+
66
+ ## Configuration
67
+
68
+ `allowFrom` is required. Use it to limit who can reach inbound hooks. It can be a single phone
69
+ number, a static list, or a resolver that runs for each inbound webhook:
70
+
71
+ ```ts
72
+ import { twilioChannel } from "experimental-ash/channels/twilio";
73
+
74
+ export default twilioChannel({
75
+ allowFrom: "+15551234567",
76
+ });
77
+ ```
78
+
79
+ ```ts
80
+ export default twilioChannel({
81
+ allowFrom: ["+15551234567", "+15557654321"],
82
+ });
83
+ ```
84
+
85
+ ```ts
86
+ export default twilioChannel({
87
+ allowFrom: async () => await loadAllowedPhoneNumbers(),
88
+ });
89
+ ```
90
+
91
+ You can pass `allowFrom: "*"` to allow every verified Twilio sender, but this is dangerous for
92
+ public phone numbers. When you use `"*"`, check the incoming number in `onText` and `onVoice` before
93
+ accepting or dispatching:
94
+
95
+ ```ts
96
+ export default twilioChannel({
97
+ allowFrom: "*",
98
+ async onText(ctx, message) {
99
+ if (!(await isAllowedPhoneNumber(message.from, message.to))) return null;
100
+ return {
101
+ auth: {
102
+ principalId: message.from,
103
+ principalType: "user",
104
+ authenticator: "twilio",
105
+ attributes: { to: message.to ?? "" },
106
+ },
107
+ };
108
+ },
109
+ async onVoice(ctx, call) {
110
+ if (!(await isAllowedPhoneNumber(call.from, call.to))) return null;
111
+ return {
112
+ language: "en-US",
113
+ prompt: "How can I help?",
114
+ speechModel: "phone_call",
115
+ voice: "Polly.Joanna-Neural",
116
+ };
117
+ },
118
+ });
119
+ ```
120
+
121
+ Override outbound sender/API defaults as needed:
122
+
123
+ ```ts
124
+ export default twilioChannel({
125
+ allowFrom: ["+15551234567"],
126
+ credentials: {
127
+ accountSid: process.env.TWILIO_ACCOUNT_SID,
128
+ authToken: process.env.TWILIO_AUTH_TOKEN,
129
+ },
130
+ messaging: {
131
+ from: "+15557654321",
132
+ // or messagingServiceSid: "MG...",
133
+ },
134
+ api: {
135
+ apiBaseUrl: "https://api.twilio.com",
136
+ },
137
+ });
138
+ ```
139
+
140
+ If a proxy or tunnel changes the URL Ash sees, set `webhookUrl` so signature verification uses the
141
+ exact URL configured in Twilio. For voice, set `publicBaseUrl` when TwiML needs absolute callback
142
+ URLs from a different public origin.
143
+
144
+ For a successful production setup, make sure the deployed app has either environment variables or
145
+ channel options for:
146
+
147
+ - Inbound verification: `TWILIO_AUTH_TOKEN` or `credentials.authToken`
148
+ - Default outbound SMS: `TWILIO_ACCOUNT_SID` or `credentials.accountSid`, plus the auth token
149
+ - Outbound sender: the inbound webhook `To` number, `messaging.from`, or
150
+ `messaging.messagingServiceSid`
151
+ - Voice transcript callbacks behind a proxy/tunnel: `publicBaseUrl` if Ash cannot derive the
152
+ public callback URL from the request, and `webhookUrl` if signature verification must use a
153
+ different public URL than `request.url`
154
+
155
+ ## Hooks
156
+
157
+ `onText` and `onVoiceTranscription` decide whether to dispatch and what auth to use. Return
158
+ `{ auth }` to dispatch or `null` to drop the webhook. `onVoice` runs when a call arrives; return
159
+ `null` to reject the call. Any other result accepts it, and an object can override the spoken
160
+ prompt, language, Twilio `<Say voice>`, and speech-recognition options.
161
+
162
+ ```ts
163
+ export default twilioChannel({
164
+ allowFrom: ["+15551234567"],
165
+ onText(ctx, message) {
166
+ return {
167
+ auth: {
168
+ principalId: message.from,
169
+ principalType: "user",
170
+ authenticator: "twilio",
171
+ attributes: { to: message.to ?? "" },
172
+ },
173
+ };
174
+ },
175
+ });
176
+ ```
177
+
178
+ The default `"message.completed"` event handler sends the agent's response as an SMS through
179
+ Twilio's Messages API. Replace `events["message.completed"]` if you want custom delivery.
@@ -84,6 +84,25 @@ Channel and Slack types exported from `experimental-ash/channels/slack`:
84
84
  - `Card`, `Button`, `Actions`, `Section`, `Modal`, `Table`, etc. - card builders re-exported for
85
85
  rendering Slack messages
86
86
 
87
+ Channel and Twilio types exported from `experimental-ash/channels/twilio`:
88
+
89
+ - `twilioChannel` - Twilio channel factory for SMS and speech-transcribed voice webhooks
90
+ - `TwilioChannelConfig` - config type for routes, credentials, allow-listing, outbound messaging,
91
+ voice prompts, API overrides, and event handlers
92
+ - `TwilioAllowFrom` - single-number, static-list, or dynamic phone-number policy used by
93
+ `allowFrom`
94
+ - `TwilioContext` - pre-dispatch context for `onText`, `onVoice`, and `onVoiceTranscription`
95
+ (`twilio`)
96
+ - `TwilioEventContext` - event-handler context (`twilio`, plus mutable `state`)
97
+ - `TwilioHandle` - Twilio handle with `from`, `to`, `callSid`, `request()`, `sendMessage()`, and
98
+ `updateCall()`
99
+ - `TwilioTextMessage` - parsed inbound SMS payload (`from`, `to`, `body`, `messageSid`, ...)
100
+ - `TwilioVoiceCall` - parsed inbound call payload passed to `onVoice`
101
+ - `TwilioVoiceResult` - call-answering options returned by `onVoice`
102
+ - `TwilioVoiceTranscription` - parsed voice transcript payload (`from`, `to`, `callSid`, `text`,
103
+ `confidence`, ...)
104
+ - `verifyTwilioRequest`, `signTwilioRequest` - Ash-owned Twilio webhook signature helpers
105
+
87
106
  Channel types exported from `experimental-ash/channels`:
88
107
 
89
108
  - `defineChannel` - channel primitive with `routes`, `state`, `context()`, and event handlers
@@ -6,7 +6,7 @@ import { ASH_PACKAGE_NAME } from "#package-name.js";
6
6
  let cachedPackageInfo;
7
7
  // The package build stamps the published version into `dist` so bundled
8
8
  // deployments can still report package metadata without resolving package.json.
9
- const BUNDLED_FALLBACK_PACKAGE_VERSION = "0.13.0";
9
+ const BUNDLED_FALLBACK_PACKAGE_VERSION = "0.14.0";
10
10
  const BUNDLED_FALLBACK_PACKAGE_VERSION_PLACEHOLDER = "__ASH_PACKAGE_VERSION_PLACEHOLDER__";
11
11
  const WORKFLOW_MODULE_ALIASES = {
12
12
  "workflow/api": "src/compiled/@workflow/core/runtime.js",
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Minimal Twilio REST API wrapper used by the Twilio channel.
3
+ *
4
+ * Requests use Twilio's normal `application/x-www-form-urlencoded`
5
+ * body encoding and HTTP Basic auth. No Twilio SDK dependency is
6
+ * required or exposed through Ash public APIs.
7
+ */
8
+ import { type TwilioAuthToken } from "#public/channels/twilio/verify.js";
9
+ /** Twilio Account SID, materialized directly or from an async secret provider. */
10
+ export type TwilioAccountSid = string | (() => string | Promise<string>);
11
+ /** Fetch implementation override used by tests or non-standard runtimes. */
12
+ export type TwilioFetch = typeof fetch;
13
+ /** Credentials required for Twilio REST API calls and webhook verification. */
14
+ export interface TwilioCredentials {
15
+ readonly accountSid?: TwilioAccountSid;
16
+ readonly authToken?: TwilioAuthToken;
17
+ }
18
+ /** Shared Twilio REST API options. */
19
+ export interface TwilioApiOptions {
20
+ readonly credentials?: TwilioCredentials;
21
+ readonly apiBaseUrl?: string;
22
+ readonly fetch?: TwilioFetch;
23
+ }
24
+ /** Raw Twilio REST API response body. */
25
+ export interface TwilioApiResponse {
26
+ readonly status: number;
27
+ readonly ok: boolean;
28
+ readonly body: unknown;
29
+ }
30
+ /** Parameters for creating an outbound Twilio message. */
31
+ export interface TwilioSendMessageInput extends TwilioApiOptions {
32
+ readonly to: string;
33
+ readonly body: string;
34
+ readonly from?: string;
35
+ readonly messagingServiceSid?: string;
36
+ readonly statusCallbackUrl?: string;
37
+ }
38
+ /** Parameters for updating a live Twilio call with new TwiML. */
39
+ export interface TwilioUpdateCallInput extends TwilioApiOptions {
40
+ readonly callSid: string;
41
+ readonly twiml: string;
42
+ }
43
+ /** Resolves a Twilio Account SID, falling back to `TWILIO_ACCOUNT_SID`. */
44
+ export declare function resolveTwilioAccountSid(accountSid?: TwilioAccountSid): Promise<string>;
45
+ /**
46
+ * Calls Twilio's REST API with Basic auth and form-encoded body fields.
47
+ *
48
+ * `path` is relative to `https://api.twilio.com` by default and may be
49
+ * pointed elsewhere through `apiBaseUrl` for tests or proxies.
50
+ */
51
+ export declare function callTwilioApi(input: {
52
+ readonly credentials?: TwilioCredentials;
53
+ readonly apiBaseUrl?: string;
54
+ readonly fetch?: TwilioFetch;
55
+ readonly path: string;
56
+ readonly body: Readonly<Record<string, string | number | boolean | undefined | null>>;
57
+ }): Promise<TwilioApiResponse>;
58
+ /** Sends an outbound SMS/MMS-style message via Twilio's Messages resource. */
59
+ export declare function sendTwilioMessage(input: TwilioSendMessageInput): Promise<TwilioApiResponse>;
60
+ /** Updates a live Twilio call by posting replacement TwiML to the Calls resource. */
61
+ export declare function updateTwilioCall(input: TwilioUpdateCallInput): Promise<TwilioApiResponse>;
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Minimal Twilio REST API wrapper used by the Twilio channel.
3
+ *
4
+ * Requests use Twilio's normal `application/x-www-form-urlencoded`
5
+ * body encoding and HTTP Basic auth. No Twilio SDK dependency is
6
+ * required or exposed through Ash public APIs.
7
+ */
8
+ import { resolveTwilioAuthToken } from "#public/channels/twilio/verify.js";
9
+ /** Resolves a Twilio Account SID, falling back to `TWILIO_ACCOUNT_SID`. */
10
+ export async function resolveTwilioAccountSid(accountSid) {
11
+ const source = accountSid ?? process.env.TWILIO_ACCOUNT_SID;
12
+ if (!source)
13
+ throw new Error("TWILIO_ACCOUNT_SID is required.");
14
+ return typeof source === "function" ? await source() : source;
15
+ }
16
+ /**
17
+ * Calls Twilio's REST API with Basic auth and form-encoded body fields.
18
+ *
19
+ * `path` is relative to `https://api.twilio.com` by default and may be
20
+ * pointed elsewhere through `apiBaseUrl` for tests or proxies.
21
+ */
22
+ export async function callTwilioApi(input) {
23
+ const accountSid = await resolveTwilioAccountSid(input.credentials?.accountSid);
24
+ const authToken = await resolveTwilioAuthToken(input.credentials?.authToken);
25
+ const apiFetch = input.fetch ?? fetch;
26
+ const url = `${input.apiBaseUrl ?? "https://api.twilio.com"}${input.path}`;
27
+ const body = encodeForm(input.body);
28
+ const response = await apiFetch(url, {
29
+ method: "POST",
30
+ headers: {
31
+ authorization: `Basic ${Buffer.from(`${accountSid}:${authToken}`).toString("base64")}`,
32
+ "content-type": "application/x-www-form-urlencoded;charset=UTF-8",
33
+ },
34
+ body,
35
+ });
36
+ return {
37
+ status: response.status,
38
+ ok: response.ok,
39
+ body: await parseResponseBody(response),
40
+ };
41
+ }
42
+ /** Sends an outbound SMS/MMS-style message via Twilio's Messages resource. */
43
+ export async function sendTwilioMessage(input) {
44
+ if (!input.from && !input.messagingServiceSid) {
45
+ throw new Error("twilioChannel: sending a message requires from or messagingServiceSid.");
46
+ }
47
+ const accountSid = await resolveTwilioAccountSid(input.credentials?.accountSid);
48
+ return callTwilioApi({
49
+ apiBaseUrl: input.apiBaseUrl,
50
+ credentials: input.credentials,
51
+ fetch: input.fetch,
52
+ path: `/2010-04-01/Accounts/${encodeURIComponent(accountSid)}/Messages.json`,
53
+ body: {
54
+ Body: input.body,
55
+ From: input.from,
56
+ MessagingServiceSid: input.messagingServiceSid,
57
+ StatusCallback: input.statusCallbackUrl,
58
+ To: input.to,
59
+ },
60
+ });
61
+ }
62
+ /** Updates a live Twilio call by posting replacement TwiML to the Calls resource. */
63
+ export async function updateTwilioCall(input) {
64
+ const accountSid = await resolveTwilioAccountSid(input.credentials?.accountSid);
65
+ return callTwilioApi({
66
+ apiBaseUrl: input.apiBaseUrl,
67
+ credentials: input.credentials,
68
+ fetch: input.fetch,
69
+ path: `/2010-04-01/Accounts/${encodeURIComponent(accountSid)}/Calls/${encodeURIComponent(input.callSid)}.json`,
70
+ body: { Twiml: input.twiml },
71
+ });
72
+ }
73
+ function encodeForm(body) {
74
+ const params = new URLSearchParams();
75
+ for (const [key, value] of Object.entries(body)) {
76
+ if (value === undefined || value === null)
77
+ continue;
78
+ params.set(key, String(value));
79
+ }
80
+ return params;
81
+ }
82
+ async function parseResponseBody(response) {
83
+ const text = await response.text();
84
+ if (!text)
85
+ return null;
86
+ try {
87
+ return JSON.parse(text);
88
+ }
89
+ catch {
90
+ return text;
91
+ }
92
+ }
@@ -0,0 +1,17 @@
1
+ import type { SessionAuthContext } from "#channel/types.js";
2
+ import type { TwilioTextMessage, TwilioVoiceCall, TwilioVoiceTranscription } from "#public/channels/twilio/inbound.js";
3
+ import type { TwilioChannelEvents, TwilioContext, TwilioInboundResult, TwilioVoiceResult } from "#public/channels/twilio/twilioChannel.js";
4
+ /** Default phone-number auth projection for Twilio webhook actors. */
5
+ export declare function defaultTwilioAuth(input: {
6
+ readonly from: string;
7
+ readonly to?: string;
8
+ readonly channel: "text" | "voice";
9
+ }): SessionAuthContext;
10
+ /** Default inbound text hook: dispatch with Twilio phone-number auth. */
11
+ export declare function defaultOnText(_ctx: TwilioContext, message: TwilioTextMessage): TwilioInboundResult;
12
+ /** Default inbound voice hook: accept the call with configured voice defaults. */
13
+ export declare function defaultOnVoice(_ctx: TwilioContext, _call: TwilioVoiceCall): TwilioVoiceResult;
14
+ /** Default inbound voice hook: dispatch with Twilio phone-number auth. */
15
+ export declare function defaultOnVoiceTranscription(_ctx: TwilioContext, transcription: TwilioVoiceTranscription): TwilioInboundResult;
16
+ /** Built-in Twilio event handlers for text delivery and terminal errors. */
17
+ export declare const defaultEvents: TwilioChannelEvents;
@@ -0,0 +1,69 @@
1
+ import { extractErrorId, formatErrorHint } from "#internal/logging.js";
2
+ /** Default phone-number auth projection for Twilio webhook actors. */
3
+ export function defaultTwilioAuth(input) {
4
+ const attributes = {
5
+ channel: input.channel,
6
+ from: input.from,
7
+ };
8
+ if (input.to !== undefined)
9
+ attributes.to = input.to;
10
+ return {
11
+ attributes,
12
+ authenticator: "twilio-webhook",
13
+ issuer: "twilio",
14
+ principalId: `twilio:${input.from}`,
15
+ principalType: "user",
16
+ };
17
+ }
18
+ /** Default inbound text hook: dispatch with Twilio phone-number auth. */
19
+ export function defaultOnText(_ctx, message) {
20
+ return {
21
+ auth: defaultTwilioAuth({
22
+ channel: "text",
23
+ from: message.from,
24
+ to: message.to,
25
+ }),
26
+ };
27
+ }
28
+ /** Default inbound voice hook: accept the call with configured voice defaults. */
29
+ export function defaultOnVoice(_ctx, _call) {
30
+ return {};
31
+ }
32
+ /** Default inbound voice hook: dispatch with Twilio phone-number auth. */
33
+ export function defaultOnVoiceTranscription(_ctx, transcription) {
34
+ return {
35
+ auth: defaultTwilioAuth({
36
+ channel: "voice",
37
+ from: transcription.from,
38
+ to: transcription.to,
39
+ }),
40
+ };
41
+ }
42
+ /** Built-in Twilio event handlers for text delivery and terminal errors. */
43
+ export const defaultEvents = {
44
+ async "message.completed"(event, ctx) {
45
+ if (event.finishReason === "tool-calls" || !event.message)
46
+ return;
47
+ await ctx.twilio.sendMessage(event.message);
48
+ },
49
+ async "turn.failed"(event, ctx) {
50
+ const hint = formatErrorHint(event);
51
+ const errorId = extractErrorId(event.details);
52
+ await ctx.twilio.sendMessage([
53
+ `I hit an error while handling your request${hint}.`,
54
+ "",
55
+ "Please try again, rephrase, or reach out if it keeps failing.",
56
+ ...(errorId ? ["", `Error id: ${errorId}`] : []),
57
+ ].join("\n"));
58
+ },
59
+ async "session.failed"(event, ctx) {
60
+ const hint = formatErrorHint(event);
61
+ const errorId = extractErrorId(event.details);
62
+ await ctx.twilio.sendMessage([
63
+ `This session could not recover from an error${hint}.`,
64
+ "",
65
+ "Start a new message to continue.",
66
+ ...(errorId ? ["", `Error id: ${errorId}`] : []),
67
+ ].join("\n"));
68
+ },
69
+ };
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Twilio inbound webhook parsing and prompt shaping.
3
+ *
4
+ * The channel owns these small data shapes instead of exposing raw
5
+ * Twilio webhook payloads as the public API surface.
6
+ */
7
+ import type { UserContent } from "ai";
8
+ /** Channel-owned representation of one inbound Twilio text message. */
9
+ export interface TwilioTextMessage {
10
+ readonly from: string;
11
+ readonly to: string | undefined;
12
+ readonly body: string;
13
+ readonly messageSid: string | undefined;
14
+ readonly accountSid: string | undefined;
15
+ readonly raw: URLSearchParams;
16
+ }
17
+ /** Channel-owned representation of one inbound Twilio voice call. */
18
+ export interface TwilioVoiceCall {
19
+ readonly from: string;
20
+ readonly to: string | undefined;
21
+ readonly callSid: string | undefined;
22
+ readonly accountSid: string | undefined;
23
+ readonly raw: URLSearchParams;
24
+ }
25
+ /** Channel-owned representation of one inbound Twilio voice transcription. */
26
+ export interface TwilioVoiceTranscription {
27
+ readonly from: string;
28
+ readonly to: string | undefined;
29
+ readonly callSid: string | undefined;
30
+ readonly text: string;
31
+ readonly confidence: number | undefined;
32
+ readonly transcriptionSid: string | undefined;
33
+ readonly raw: URLSearchParams;
34
+ }
35
+ /** Inbound identity and response guidance rendered into the model-visible `<twilio_context>` block. */
36
+ export interface TwilioInboundContext {
37
+ readonly from: string;
38
+ readonly to?: string;
39
+ readonly messageSid?: string;
40
+ readonly callSid?: string;
41
+ readonly channel: "text" | "voice";
42
+ }
43
+ /** Parses Twilio's incoming-message webhook fields. */
44
+ export declare function parseTwilioTextMessage(params: URLSearchParams): TwilioTextMessage | null;
45
+ /** Parses Twilio's incoming-call webhook fields. */
46
+ export declare function parseTwilioVoiceCall(params: URLSearchParams): TwilioVoiceCall | null;
47
+ /**
48
+ * Parses Twilio voice transcription fields.
49
+ *
50
+ * Supports `<Gather input="speech">` (`SpeechResult`), recording
51
+ * transcription callbacks (`TranscriptionText`), and real-time
52
+ * transcription callbacks (`TranscriptionData` JSON). Real-time partial
53
+ * results are ignored until Twilio marks them final.
54
+ */
55
+ export declare function parseTwilioVoiceTranscription(params: URLSearchParams): TwilioVoiceTranscription | null;
56
+ /** Renders a deterministic `<twilio_context>` block for the model. */
57
+ export declare function formatTwilioContextBlock(context: TwilioInboundContext): string;
58
+ /** Prepends a `<twilio_context>` block to the inbound turn message. */
59
+ export declare function prependTwilioContext(message: string | UserContent, context: TwilioInboundContext): string | UserContent;
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Twilio inbound webhook parsing and prompt shaping.
3
+ *
4
+ * The channel owns these small data shapes instead of exposing raw
5
+ * Twilio webhook payloads as the public API surface.
6
+ */
7
+ const TWILIO_SMS_RESPONSE_INSTRUCTIONS = "Reply for SMS in plain text. Keep the response concise and avoid Markdown formatting, " +
8
+ "tables, headings, code fences, and long lists. Ask at most one short follow-up question " +
9
+ "when more information is needed.";
10
+ /** Parses Twilio's incoming-message webhook fields. */
11
+ export function parseTwilioTextMessage(params) {
12
+ const from = requiredParam(params, "From");
13
+ const body = requiredParam(params, "Body");
14
+ if (!from || !body)
15
+ return null;
16
+ return {
17
+ accountSid: optionalParam(params, "AccountSid"),
18
+ body,
19
+ from,
20
+ messageSid: optionalParam(params, "MessageSid") ?? optionalParam(params, "SmsMessageSid"),
21
+ raw: params,
22
+ to: optionalParam(params, "To"),
23
+ };
24
+ }
25
+ /** Parses Twilio's incoming-call webhook fields. */
26
+ export function parseTwilioVoiceCall(params) {
27
+ const from = requiredParam(params, "From") ?? requiredParam(params, "Caller");
28
+ if (!from)
29
+ return null;
30
+ return {
31
+ accountSid: optionalParam(params, "AccountSid"),
32
+ callSid: optionalParam(params, "CallSid"),
33
+ from,
34
+ raw: params,
35
+ to: optionalParam(params, "To") ?? optionalParam(params, "Called"),
36
+ };
37
+ }
38
+ /**
39
+ * Parses Twilio voice transcription fields.
40
+ *
41
+ * Supports `<Gather input="speech">` (`SpeechResult`), recording
42
+ * transcription callbacks (`TranscriptionText`), and real-time
43
+ * transcription callbacks (`TranscriptionData` JSON). Real-time partial
44
+ * results are ignored until Twilio marks them final.
45
+ */
46
+ export function parseTwilioVoiceTranscription(params) {
47
+ const from = requiredParam(params, "From") ?? requiredParam(params, "Caller");
48
+ if (!from)
49
+ return null;
50
+ const parsedData = parseTranscriptionData(optionalParam(params, "TranscriptionData"));
51
+ const final = optionalParam(params, "Final");
52
+ if (final === "false")
53
+ return null;
54
+ const text = optionalParam(params, "SpeechResult") ??
55
+ optionalParam(params, "TranscriptionText") ??
56
+ parsedData?.transcript ??
57
+ "";
58
+ if (!text.trim())
59
+ return null;
60
+ return {
61
+ callSid: optionalParam(params, "CallSid"),
62
+ confidence: parseConfidence(optionalParam(params, "Confidence") ?? parsedData?.confidence),
63
+ from,
64
+ raw: params,
65
+ text,
66
+ to: optionalParam(params, "To") ?? optionalParam(params, "Called"),
67
+ transcriptionSid: optionalParam(params, "TranscriptionSid"),
68
+ };
69
+ }
70
+ /** Renders a deterministic `<twilio_context>` block for the model. */
71
+ export function formatTwilioContextBlock(context) {
72
+ const lines = [
73
+ "<twilio_context>",
74
+ `channel: ${context.channel}`,
75
+ "response_medium: sms",
76
+ `response_instructions: ${TWILIO_SMS_RESPONSE_INSTRUCTIONS}`,
77
+ `from: ${context.from}`,
78
+ ...(context.to ? [`to: ${context.to}`] : []),
79
+ ...(context.messageSid ? [`message_sid: ${context.messageSid}`] : []),
80
+ ...(context.callSid ? [`call_sid: ${context.callSid}`] : []),
81
+ "</twilio_context>",
82
+ ];
83
+ return lines.join("\n");
84
+ }
85
+ /** Prepends a `<twilio_context>` block to the inbound turn message. */
86
+ export function prependTwilioContext(message, context) {
87
+ const block = formatTwilioContextBlock(context);
88
+ if (typeof message === "string") {
89
+ return message.length > 0 ? `${block}\n\n${message}` : block;
90
+ }
91
+ const contextPart = { type: "text", text: block };
92
+ return [contextPart, ...message];
93
+ }
94
+ function requiredParam(params, name) {
95
+ const value = params.get(name);
96
+ return value && value.trim().length > 0 ? value : null;
97
+ }
98
+ function optionalParam(params, name) {
99
+ const value = params.get(name);
100
+ return value === null || value.length === 0 ? undefined : value;
101
+ }
102
+ function parseTranscriptionData(value) {
103
+ if (!value)
104
+ return null;
105
+ try {
106
+ const parsed = JSON.parse(value);
107
+ return {
108
+ confidence: typeof parsed.confidence === "number" || typeof parsed.confidence === "string"
109
+ ? String(parsed.confidence)
110
+ : undefined,
111
+ transcript: typeof parsed.transcript === "string" ? parsed.transcript : undefined,
112
+ };
113
+ }
114
+ catch {
115
+ return null;
116
+ }
117
+ }
118
+ function parseConfidence(value) {
119
+ if (value === undefined)
120
+ return undefined;
121
+ const parsed = Number(value);
122
+ return Number.isFinite(parsed) ? parsed : undefined;
123
+ }