@personaai/runtime 0.16.2 → 0.17.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/dist/index.d.cts CHANGED
@@ -265,6 +265,70 @@ interface RcpManifestOptions {
265
265
  */
266
266
  authToken?: string;
267
267
  }
268
+ /**
269
+ * A Twilio webhook request that reached this runtime, after its signature
270
+ * was verified. `from` is the end user's number (WhatsApp numbers keep the
271
+ * `whatsapp:` prefix), `body` is the message text on the message channel.
272
+ */
273
+ interface TwilioChannelRequest {
274
+ channel: 'voice' | 'whatsapp' | 'sms';
275
+ from: string;
276
+ to: string;
277
+ /** Present on the voice channel. */
278
+ callSid?: string;
279
+ /** Present on the message channel. */
280
+ messageSid?: string;
281
+ /** Present on the message channel — the inbound text. */
282
+ body?: string;
283
+ /** WhatsApp profile name, when Twilio supplies one. */
284
+ profileName?: string;
285
+ }
286
+ /**
287
+ * Return value of {@link TwilioChannelOptions.resolveUser}: the developer's
288
+ * own user id (which becomes the Persona external user id), optionally with
289
+ * a per-request Agent override. `null` means "not one of my users" — the
290
+ * runtime then rejects the call / sends no reply.
291
+ */
292
+ interface TwilioChannelUser {
293
+ externalUserId: string;
294
+ agentId?: string;
295
+ /** Continue an existing thread instead of the per-user default. */
296
+ threadId?: string;
297
+ }
298
+ type ResolveTwilioChannelUser = (request: TwilioChannelRequest) => TwilioChannelUser | string | null | Promise<TwilioChannelUser | string | null>;
299
+ /**
300
+ * Bring-your-own-Twilio-number webhooks, served by this runtime:
301
+ * `POST /channels/twilio/voice` and `POST /channels/twilio/message`.
302
+ *
303
+ * Both routes verify Twilio's `X-Twilio-Signature` with YOUR auth token,
304
+ * resolve the sender to one of your users, and proxy to Persona. Voice
305
+ * returns TwiML that points Twilio's media stream straight at Persona (your
306
+ * server never relays audio); messaging returns the Agent's reply as TwiML,
307
+ * so no message is sent from Persona's own account.
308
+ */
309
+ interface TwilioChannelOptions {
310
+ /** Your Twilio Auth Token — used only to verify inbound webhook signatures. */
311
+ authToken: string;
312
+ /**
313
+ * The exact public origin Twilio calls, e.g. `https://app.example.com`.
314
+ * Twilio signs the full URL, so a proxy/rewrite that changes it breaks
315
+ * verification — set this when the request's own host headers aren't the
316
+ * public URL. Omit to derive it from `x-forwarded-proto` / `host`.
317
+ */
318
+ publicUrl?: string;
319
+ /** Maps a Twilio sender to one of your users. Return `null` to ignore the message/call. */
320
+ resolveUser: ResolveTwilioChannelUser;
321
+ /** Default Agent for both channels. `resolveUser` may override it per request. */
322
+ agentId?: string;
323
+ /** Spoken instead of the Agent's voice when the session cannot start. */
324
+ voiceFallback?: string;
325
+ /** Texted instead of the Agent's reply when the turn fails. `''` disables it. */
326
+ messageFallback?: string;
327
+ }
328
+ interface ChannelsOptions {
329
+ /** Enables the two Twilio channel routes. Unset by default. */
330
+ twilio?: TwilioChannelOptions;
331
+ }
268
332
  interface CreateRuntimeOptions {
269
333
  /** Base URL of the Persona Developer Platform API, e.g. "https://api.persona.hasanraiyan.me". */
270
334
  baseUrl: string;
@@ -311,6 +375,12 @@ interface CreateRuntimeOptions {
311
375
  restToolsManifest?: RestToolsManifestOptions;
312
376
  /** Serves a conformant RCP manifest for any RCP client to discover. See {@link RcpManifestOptions}. Unset by default — the route only exists when this is provided. */
313
377
  rcpManifest?: RcpManifestOptions;
378
+ /**
379
+ * Bring-your-own-Twilio-number webhook routes (`/channels/twilio/*`). See
380
+ * {@link ChannelsOptions}. Unset by default — the routes only exist when
381
+ * this is provided.
382
+ */
383
+ channels?: ChannelsOptions;
314
384
  /** Log level for the runtime — off by default. Overrides the global level set via `setLogLevel()` from `@personaai/sdk`. */
315
385
  logLevel?: LogLevel;
316
386
  /** Custom logger instance — when provided, `logLevel` is ignored. */
@@ -337,6 +407,6 @@ declare class RuntimeHttpError extends Error {
337
407
  constructor(status: number, code: string, message: string);
338
408
  }
339
409
 
340
- declare const RUNTIME_VERSION = "0.16.1";
410
+ declare const RUNTIME_VERSION = "0.17.0";
341
411
 
342
- export { type CreateRuntimeOptions, type ErrorContext, type FileUploadContext, type MemoryWriteContext, RUNTIME_VERSION, type RcpManifestOptions, type ResolveUser, type RestToolManifestEntry, type RestToolsManifestOptions, type RunContext, type RunResult, type Runtime, type RuntimeBinaryResponse, type RuntimeBufferedResponse, type RuntimeCapabilities, type RuntimeHooks, RuntimeHttpError, type RuntimeMethod, type RuntimeRequest, type RuntimeResponse, type RuntimeStreamResponse, type RuntimeUploadedFile, type ThreadCreateContext, type ToolCallContext, type VoiceSessionCreateContext, type WorkflowNodeContext, createRuntime };
412
+ export { type ChannelsOptions, type CreateRuntimeOptions, type ErrorContext, type FileUploadContext, type MemoryWriteContext, RUNTIME_VERSION, type RcpManifestOptions, type ResolveTwilioChannelUser, type ResolveUser, type RestToolManifestEntry, type RestToolsManifestOptions, type RunContext, type RunResult, type Runtime, type RuntimeBinaryResponse, type RuntimeBufferedResponse, type RuntimeCapabilities, type RuntimeHooks, RuntimeHttpError, type RuntimeMethod, type RuntimeRequest, type RuntimeResponse, type RuntimeStreamResponse, type RuntimeUploadedFile, type ThreadCreateContext, type ToolCallContext, type TwilioChannelOptions, type TwilioChannelRequest, type TwilioChannelUser, type VoiceSessionCreateContext, type WorkflowNodeContext, createRuntime };
package/dist/index.d.ts CHANGED
@@ -265,6 +265,70 @@ interface RcpManifestOptions {
265
265
  */
266
266
  authToken?: string;
267
267
  }
268
+ /**
269
+ * A Twilio webhook request that reached this runtime, after its signature
270
+ * was verified. `from` is the end user's number (WhatsApp numbers keep the
271
+ * `whatsapp:` prefix), `body` is the message text on the message channel.
272
+ */
273
+ interface TwilioChannelRequest {
274
+ channel: 'voice' | 'whatsapp' | 'sms';
275
+ from: string;
276
+ to: string;
277
+ /** Present on the voice channel. */
278
+ callSid?: string;
279
+ /** Present on the message channel. */
280
+ messageSid?: string;
281
+ /** Present on the message channel — the inbound text. */
282
+ body?: string;
283
+ /** WhatsApp profile name, when Twilio supplies one. */
284
+ profileName?: string;
285
+ }
286
+ /**
287
+ * Return value of {@link TwilioChannelOptions.resolveUser}: the developer's
288
+ * own user id (which becomes the Persona external user id), optionally with
289
+ * a per-request Agent override. `null` means "not one of my users" — the
290
+ * runtime then rejects the call / sends no reply.
291
+ */
292
+ interface TwilioChannelUser {
293
+ externalUserId: string;
294
+ agentId?: string;
295
+ /** Continue an existing thread instead of the per-user default. */
296
+ threadId?: string;
297
+ }
298
+ type ResolveTwilioChannelUser = (request: TwilioChannelRequest) => TwilioChannelUser | string | null | Promise<TwilioChannelUser | string | null>;
299
+ /**
300
+ * Bring-your-own-Twilio-number webhooks, served by this runtime:
301
+ * `POST /channels/twilio/voice` and `POST /channels/twilio/message`.
302
+ *
303
+ * Both routes verify Twilio's `X-Twilio-Signature` with YOUR auth token,
304
+ * resolve the sender to one of your users, and proxy to Persona. Voice
305
+ * returns TwiML that points Twilio's media stream straight at Persona (your
306
+ * server never relays audio); messaging returns the Agent's reply as TwiML,
307
+ * so no message is sent from Persona's own account.
308
+ */
309
+ interface TwilioChannelOptions {
310
+ /** Your Twilio Auth Token — used only to verify inbound webhook signatures. */
311
+ authToken: string;
312
+ /**
313
+ * The exact public origin Twilio calls, e.g. `https://app.example.com`.
314
+ * Twilio signs the full URL, so a proxy/rewrite that changes it breaks
315
+ * verification — set this when the request's own host headers aren't the
316
+ * public URL. Omit to derive it from `x-forwarded-proto` / `host`.
317
+ */
318
+ publicUrl?: string;
319
+ /** Maps a Twilio sender to one of your users. Return `null` to ignore the message/call. */
320
+ resolveUser: ResolveTwilioChannelUser;
321
+ /** Default Agent for both channels. `resolveUser` may override it per request. */
322
+ agentId?: string;
323
+ /** Spoken instead of the Agent's voice when the session cannot start. */
324
+ voiceFallback?: string;
325
+ /** Texted instead of the Agent's reply when the turn fails. `''` disables it. */
326
+ messageFallback?: string;
327
+ }
328
+ interface ChannelsOptions {
329
+ /** Enables the two Twilio channel routes. Unset by default. */
330
+ twilio?: TwilioChannelOptions;
331
+ }
268
332
  interface CreateRuntimeOptions {
269
333
  /** Base URL of the Persona Developer Platform API, e.g. "https://api.persona.hasanraiyan.me". */
270
334
  baseUrl: string;
@@ -311,6 +375,12 @@ interface CreateRuntimeOptions {
311
375
  restToolsManifest?: RestToolsManifestOptions;
312
376
  /** Serves a conformant RCP manifest for any RCP client to discover. See {@link RcpManifestOptions}. Unset by default — the route only exists when this is provided. */
313
377
  rcpManifest?: RcpManifestOptions;
378
+ /**
379
+ * Bring-your-own-Twilio-number webhook routes (`/channels/twilio/*`). See
380
+ * {@link ChannelsOptions}. Unset by default — the routes only exist when
381
+ * this is provided.
382
+ */
383
+ channels?: ChannelsOptions;
314
384
  /** Log level for the runtime — off by default. Overrides the global level set via `setLogLevel()` from `@personaai/sdk`. */
315
385
  logLevel?: LogLevel;
316
386
  /** Custom logger instance — when provided, `logLevel` is ignored. */
@@ -337,6 +407,6 @@ declare class RuntimeHttpError extends Error {
337
407
  constructor(status: number, code: string, message: string);
338
408
  }
339
409
 
340
- declare const RUNTIME_VERSION = "0.16.1";
410
+ declare const RUNTIME_VERSION = "0.17.0";
341
411
 
342
- export { type CreateRuntimeOptions, type ErrorContext, type FileUploadContext, type MemoryWriteContext, RUNTIME_VERSION, type RcpManifestOptions, type ResolveUser, type RestToolManifestEntry, type RestToolsManifestOptions, type RunContext, type RunResult, type Runtime, type RuntimeBinaryResponse, type RuntimeBufferedResponse, type RuntimeCapabilities, type RuntimeHooks, RuntimeHttpError, type RuntimeMethod, type RuntimeRequest, type RuntimeResponse, type RuntimeStreamResponse, type RuntimeUploadedFile, type ThreadCreateContext, type ToolCallContext, type VoiceSessionCreateContext, type WorkflowNodeContext, createRuntime };
412
+ export { type ChannelsOptions, type CreateRuntimeOptions, type ErrorContext, type FileUploadContext, type MemoryWriteContext, RUNTIME_VERSION, type RcpManifestOptions, type ResolveTwilioChannelUser, type ResolveUser, type RestToolManifestEntry, type RestToolsManifestOptions, type RunContext, type RunResult, type Runtime, type RuntimeBinaryResponse, type RuntimeBufferedResponse, type RuntimeCapabilities, type RuntimeHooks, RuntimeHttpError, type RuntimeMethod, type RuntimeRequest, type RuntimeResponse, type RuntimeStreamResponse, type RuntimeUploadedFile, type ThreadCreateContext, type ToolCallContext, type TwilioChannelOptions, type TwilioChannelRequest, type TwilioChannelUser, type VoiceSessionCreateContext, type WorkflowNodeContext, createRuntime };
package/dist/index.js CHANGED
@@ -131,7 +131,7 @@ function evictStaleRuns(runs, now, graceMs = DEFAULT_RUN_GRACE_MS, maxTracked =
131
131
  }
132
132
 
133
133
  // src/version.ts
134
- var RUNTIME_VERSION = "0.16.1";
134
+ var RUNTIME_VERSION = "0.17.0";
135
135
 
136
136
  // src/routes/health.ts
137
137
  var alwaysOnCapabilities = {
@@ -1653,6 +1653,203 @@ var workflowStreamRoute = async (request, ctx) => {
1653
1653
  };
1654
1654
  };
1655
1655
 
1656
+ // src/routes/channels.ts
1657
+ var SIGNATURE_HEADER = "x-twilio-signature";
1658
+ var DEFAULT_VOICE_FALLBACK = "Sorry, this assistant is unavailable right now. Please try again later.";
1659
+ var DEFAULT_MESSAGE_FALLBACK = "Sorry, something went wrong while handling your message. Please try again.";
1660
+ function firstHeader(value) {
1661
+ return value?.split(",")[0]?.trim() || void 0;
1662
+ }
1663
+ function resolveSignedUrl(request, publicUrl) {
1664
+ const search = new URLSearchParams();
1665
+ for (const [key, value] of Object.entries(request.query)) {
1666
+ if (value !== void 0) search.set(key, value);
1667
+ }
1668
+ const query = search.toString();
1669
+ const pathWithQuery = request.path + (query ? `?${query}` : "");
1670
+ if (publicUrl) {
1671
+ return `${publicUrl.replace(/\/+$/, "")}${pathWithQuery}`;
1672
+ }
1673
+ const host = firstHeader(request.headers["x-forwarded-host"]) ?? firstHeader(request.headers.host);
1674
+ const proto = firstHeader(request.headers["x-forwarded-proto"]) ?? "https";
1675
+ if (!host) return null;
1676
+ return `${proto}://${host}${pathWithQuery}`;
1677
+ }
1678
+ async function computeTwilioSignature(authToken, url, params) {
1679
+ const payload = url + Object.keys(params).sort().map((key2) => `${key2}${params[key2]}`).join("");
1680
+ const encoder = new TextEncoder();
1681
+ const key = await crypto.subtle.importKey(
1682
+ "raw",
1683
+ encoder.encode(authToken),
1684
+ { name: "HMAC", hash: "SHA-1" },
1685
+ false,
1686
+ ["sign"]
1687
+ );
1688
+ const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(payload));
1689
+ const bytes = new Uint8Array(signature);
1690
+ let binary = "";
1691
+ for (const byte of bytes) binary += String.fromCharCode(byte);
1692
+ return btoa(binary);
1693
+ }
1694
+ function constantTimeEqual(a, b) {
1695
+ if (a.length !== b.length) return false;
1696
+ let diff = 0;
1697
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
1698
+ return diff === 0;
1699
+ }
1700
+ async function verifyTwilioSignature(request, options) {
1701
+ const provided = request.headers[SIGNATURE_HEADER];
1702
+ if (!provided || !options.authToken) return false;
1703
+ const url = resolveSignedUrl(request, options.publicUrl);
1704
+ if (!url) return false;
1705
+ const expected = await computeTwilioSignature(options.authToken, url, formParams(request));
1706
+ return constantTimeEqual(expected, provided);
1707
+ }
1708
+ function formParams(request) {
1709
+ const params = {};
1710
+ const body = request.body;
1711
+ if (!body || typeof body !== "object" || Array.isArray(body)) return params;
1712
+ for (const [key, value] of Object.entries(body)) {
1713
+ if (typeof value === "string") params[key] = value;
1714
+ else if (typeof value === "number" || typeof value === "boolean") params[key] = String(value);
1715
+ }
1716
+ return params;
1717
+ }
1718
+ function escapeXml(value) {
1719
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
1720
+ }
1721
+ function twiml(body) {
1722
+ return {
1723
+ kind: "buffered",
1724
+ status: 200,
1725
+ headers: { "content-type": "text/xml; charset=utf-8" },
1726
+ body: `<?xml version="1.0" encoding="UTF-8"?>${body}`
1727
+ };
1728
+ }
1729
+ function forbidden(message) {
1730
+ return {
1731
+ kind: "buffered",
1732
+ status: 403,
1733
+ headers: { "content-type": "text/plain; charset=utf-8" },
1734
+ body: message
1735
+ };
1736
+ }
1737
+ function channelKind(from) {
1738
+ return from.trim().toLowerCase().startsWith("whatsapp:") ? "whatsapp" : "sms";
1739
+ }
1740
+ function toUser(result) {
1741
+ if (!result) return null;
1742
+ if (typeof result === "string") return result ? { externalUserId: result } : null;
1743
+ if (typeof result.externalUserId !== "string" || !result.externalUserId) return null;
1744
+ return result;
1745
+ }
1746
+ async function authenticate(request, options, channelRequest) {
1747
+ if (!await verifyTwilioSignature(request, options)) {
1748
+ return { response: forbidden("Invalid Twilio signature") };
1749
+ }
1750
+ let resolved = null;
1751
+ try {
1752
+ resolved = await options.resolveUser(channelRequest);
1753
+ } catch {
1754
+ resolved = null;
1755
+ }
1756
+ const user = toUser(resolved);
1757
+ if (!user) {
1758
+ return {
1759
+ response: channelRequest.channel === "voice" ? twiml("<Response><Reject/></Response>") : twiml("<Response></Response>")
1760
+ };
1761
+ }
1762
+ return { user };
1763
+ }
1764
+ var twilioVoiceChannel = async (request, ctx) => {
1765
+ const options = ctx.channels?.twilio;
1766
+ if (!options) {
1767
+ throw new RuntimeHttpError(404, "NOT_FOUND", "Twilio voice channel is not configured.");
1768
+ }
1769
+ const params = formParams(request);
1770
+ const channelRequest = {
1771
+ channel: "voice",
1772
+ from: params.From ?? "",
1773
+ to: params.To ?? "",
1774
+ ...params.CallSid ? { callSid: params.CallSid } : {}
1775
+ };
1776
+ const auth = await authenticate(request, options, channelRequest);
1777
+ if ("response" in auth) return auth.response;
1778
+ const agentId = auth.user.agentId ?? options.agentId;
1779
+ const fallback = options.voiceFallback ?? DEFAULT_VOICE_FALLBACK;
1780
+ if (!agentId) {
1781
+ ctx.logger.warn("twilio voice channel has no agent configured", {
1782
+ hasDefault: false,
1783
+ resolverProvided: false
1784
+ });
1785
+ return twiml(`<Response><Say>${escapeXml(fallback)}</Say><Hangup/></Response>`);
1786
+ }
1787
+ try {
1788
+ const session = await ctx.client.voice.createTelephonySession({
1789
+ agentId,
1790
+ externalUserId: auth.user.externalUserId,
1791
+ ...auth.user.threadId ? { threadId: auth.user.threadId } : {},
1792
+ ...channelRequest.from ? { from: channelRequest.from } : {},
1793
+ ...channelRequest.to ? { to: channelRequest.to } : {},
1794
+ ...channelRequest.callSid ? { callSid: channelRequest.callSid } : {}
1795
+ });
1796
+ await ctx.hooks?.onVoiceSessionCreate?.({
1797
+ userId: auth.user.externalUserId,
1798
+ agentId
1799
+ });
1800
+ return twiml(
1801
+ `<Response><Connect><Stream url="${escapeXml(session.streamUrl)}"/></Connect></Response>`
1802
+ );
1803
+ } catch (err) {
1804
+ ctx.logger.error("twilio voice channel failed to start a session", {
1805
+ error: err instanceof Error ? err.message : String(err)
1806
+ });
1807
+ return twiml(`<Response><Say>${escapeXml(fallback)}</Say><Hangup/></Response>`);
1808
+ }
1809
+ };
1810
+ var twilioMessageChannel = async (request, ctx) => {
1811
+ const options = ctx.channels?.twilio;
1812
+ if (!options) {
1813
+ throw new RuntimeHttpError(404, "NOT_FOUND", "Twilio messaging channel is not configured.");
1814
+ }
1815
+ const params = formParams(request);
1816
+ const channelRequest = {
1817
+ channel: channelKind(params.From ?? ""),
1818
+ from: params.From ?? "",
1819
+ to: params.To ?? "",
1820
+ ...params.MessageSid ? { messageSid: params.MessageSid } : {},
1821
+ ...params.Body ? { body: params.Body } : {},
1822
+ ...params.ProfileName ? { profileName: params.ProfileName } : {}
1823
+ };
1824
+ const auth = await authenticate(request, options, channelRequest);
1825
+ if ("response" in auth) return auth.response;
1826
+ const agentId = auth.user.agentId ?? options.agentId;
1827
+ const fallback = options.messageFallback === void 0 ? DEFAULT_MESSAGE_FALLBACK : options.messageFallback;
1828
+ if (!agentId) {
1829
+ ctx.logger.warn("twilio message channel has no agent configured");
1830
+ return fallback ? twiml(`<Response><Message>${escapeXml(fallback)}</Message></Response>`) : twiml("<Response></Response>");
1831
+ }
1832
+ const text = channelRequest.body ?? "";
1833
+ if (!text.trim()) {
1834
+ return twiml("<Response></Response>");
1835
+ }
1836
+ try {
1837
+ const result = await ctx.client.messages.generateReply({
1838
+ agentId,
1839
+ externalUserId: auth.user.externalUserId,
1840
+ text,
1841
+ ...auth.user.threadId ? { threadId: auth.user.threadId } : {},
1842
+ ...channelRequest.profileName ? { displayName: channelRequest.profileName } : {}
1843
+ });
1844
+ return twiml(`<Response><Message>${escapeXml(result.reply)}</Message></Response>`);
1845
+ } catch (err) {
1846
+ ctx.logger.error("twilio message channel failed to generate a reply", {
1847
+ error: err instanceof Error ? err.message : String(err)
1848
+ });
1849
+ return fallback ? twiml(`<Response><Message>${escapeXml(fallback)}</Message></Response>`) : twiml("<Response></Response>");
1850
+ }
1851
+ };
1852
+
1656
1853
  // src/runtime.ts
1657
1854
  function resolveCapabilities(capabilities) {
1658
1855
  return {
@@ -1924,6 +2121,22 @@ function createRuntime(options) {
1924
2121
  requiresAuth: false
1925
2122
  });
1926
2123
  }
2124
+ if (options.channels?.twilio) {
2125
+ routes.push(
2126
+ {
2127
+ method: "POST",
2128
+ pattern: ["channels", "twilio", "voice"],
2129
+ handler: twilioVoiceChannel,
2130
+ requiresAuth: false
2131
+ },
2132
+ {
2133
+ method: "POST",
2134
+ pattern: ["channels", "twilio", "message"],
2135
+ handler: twilioMessageChannel,
2136
+ requiresAuth: false
2137
+ }
2138
+ );
2139
+ }
1927
2140
  const mode = resolveMode(options);
1928
2141
  const heartbeatIntervalMs = options.heartbeatIntervalMs ?? 15e3;
1929
2142
  const runGraceMs = options.runGraceMs ?? DEFAULT_RUN_GRACE_MS;
@@ -2091,7 +2304,8 @@ function createRuntime(options) {
2091
2304
  capabilities,
2092
2305
  logger: routeLogger,
2093
2306
  restToolsManifest: options.restToolsManifest,
2094
- rcpManifest: options.rcpManifest
2307
+ rcpManifest: options.rcpManifest,
2308
+ channels: options.channels
2095
2309
  });
2096
2310
  const durationMs = Date.now() - startMs;
2097
2311
  logger.info("handle succeeded", {