@andreprado/agentkit 0.1.0-alpha.22 → 0.1.0-alpha.24

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.
@@ -122,6 +122,9 @@ export type ChannelWebhookResponse = {
122
122
 
123
123
  export type ChannelSendInput = ChannelAdapterId & {
124
124
  channelId: string;
125
+ channel?: AgentChannel;
126
+ jobId?: string;
127
+ deliveryId?: string;
125
128
  conversationId: string;
126
129
  externalIdentity: ChannelExternalIdentity;
127
130
  replyTarget?: ChannelReplyTarget;
@@ -20,6 +20,7 @@ import type {
20
20
  AgentIntegration,
21
21
  ChannelAudioMode,
22
22
  TranscriptionProviderName,
23
+ ChannelReplyRouteConfig,
23
24
  } from "../index";
24
25
  import { MANAGED_COMPOSIO_TOOL_NAME } from "./integrations/composio";
25
26
  import { AgentKitError } from "./errors";
@@ -39,10 +40,12 @@ const TELEGRAM_ALLOWED_UPDATES = new Set<TelegramAllowedUpdate>(["message"]);
39
40
  const KNOWLEDGE_EMBEDDING_PROVIDERS = new Set<AgentKnowledgeEmbeddingProvider>(["none", "test", "openai"]);
40
41
  const TRANSCRIPTION_PROVIDERS = new Set<TranscriptionProviderName>(["test", "openai", "groq"]);
41
42
  const CHANNEL_NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
43
+ const CHANNEL_REPLY_PATH_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*$/;
42
44
  const INTEGRATION_NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
43
45
  const COMPOSIO_TOOLKIT_PATTERN = /^[a-z][a-z0-9_-]*$/;
44
46
  const COMPOSIO_TOOL_SLUG_PATTERN = /^[A-Z][A-Z0-9_]*$/;
45
47
  const SECRET_NAME_PATTERN = /^[A-Z_][A-Z0-9_]*$/;
48
+ const HTTP_HEADER_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9-]*$/;
46
49
  const moduleDir = dirname(fileURLToPath(import.meta.url));
47
50
  let configImportNonce = 0;
48
51
 
@@ -589,6 +592,10 @@ function validateChannels(value: unknown, runtime: AgentRuntime, hasTranscriptio
589
592
 
590
593
  names.add(channel.name);
591
594
  }
595
+
596
+ for (const [index, channel] of value.entries()) {
597
+ validateChannelReplyRoute(channel, index, names);
598
+ }
592
599
  }
593
600
 
594
601
  function validateChannel(value: unknown, index: number, hasTranscriptionConfig: boolean): asserts value is AgentChannel {
@@ -614,6 +621,7 @@ function validateChannel(value: unknown, index: number, hasTranscriptionConfig:
614
621
 
615
622
  expectStringArray(value.secrets, `channels[${index}].secrets`);
616
623
  validateSecretNames(value.secrets, `channels[${index}].secrets`);
624
+ validateChannelReplyTo(value.replyTo, index);
617
625
  validateChannelLimits(value.limits, index);
618
626
  validateChannelBuffer(value.buffer, index);
619
627
 
@@ -787,9 +795,145 @@ function validateGenericWebhookChannel(value: Record<string, unknown>, index: nu
787
795
  throw invalidConfig(`channels[${index}].audio is not supported when type is "webhook".`);
788
796
  }
789
797
 
798
+ if (value.direction !== undefined && value.direction !== "inbound" && value.direction !== "outbound") {
799
+ throw invalidConfig(`channels[${index}].direction must be "inbound" or "outbound" when type is "webhook".`);
800
+ }
801
+
802
+ if (value.direction === "outbound") {
803
+ if (value.buffer !== undefined) {
804
+ throw invalidConfig(`channels[${index}].buffer is not supported for outbound webhook channels.`);
805
+ }
806
+
807
+ if (value.replyTo !== undefined) {
808
+ throw invalidConfig(`channels[${index}].replyTo is not supported for outbound webhook channels.`);
809
+ }
810
+
811
+ expectString(value.urlSecret, `channels[${index}].urlSecret`);
812
+ validateSecretNames([value.urlSecret], `channels[${index}].urlSecret`);
813
+ validateGenericWebhookOutputAuth(value.auth, index);
814
+ expectRequiredSecrets(
815
+ value.secrets,
816
+ [value.urlSecret, ...genericWebhookOutputAuthSecrets(value.auth)],
817
+ `channels[${index}]`,
818
+ );
819
+ return;
820
+ }
821
+
822
+ if (value.urlSecret !== undefined || value.auth !== undefined) {
823
+ throw invalidConfig(`channels[${index}].urlSecret and auth are only supported for outbound webhook channels.`);
824
+ }
825
+
790
826
  expectRequiredSecrets(value.secrets, ["AGENTKIT_WEBHOOK_SECRET"], `channels[${index}]`);
791
827
  }
792
828
 
829
+ function validateChannelReplyTo(value: unknown, index: number): void {
830
+ if (value === undefined) {
831
+ return;
832
+ }
833
+
834
+ if (!isRecord(value)) {
835
+ throw invalidConfig(`channels[${index}].replyTo must be an object when provided.`);
836
+ }
837
+
838
+ expectString(value.channel, `channels[${index}].replyTo.channel`);
839
+
840
+ if (!CHANNEL_NAME_PATTERN.test(value.channel)) {
841
+ throw invalidConfig(`channels[${index}].replyTo.channel must be a valid channel name.`);
842
+ }
843
+
844
+ if (value.recipientFrom !== undefined) {
845
+ expectString(value.recipientFrom, `channels[${index}].replyTo.recipientFrom`);
846
+
847
+ if (!CHANNEL_REPLY_PATH_PATTERN.test(String(value.recipientFrom).replace(/^payload\./, ""))) {
848
+ throw invalidConfig(`channels[${index}].replyTo.recipientFrom must be a dotted JSON field path.`);
849
+ }
850
+ }
851
+ }
852
+
853
+ function validateChannelReplyRoute(
854
+ channel: unknown,
855
+ index: number,
856
+ names: Set<string>,
857
+ ): void {
858
+ if (!isRecord(channel) || !isRecord(channel.replyTo)) {
859
+ return;
860
+ }
861
+
862
+ const replyTo = channel.replyTo as ChannelReplyRouteConfig;
863
+
864
+ if (!names.has(replyTo.channel)) {
865
+ throw invalidConfig(`channels[${index}].replyTo.channel must name another channel in this Agent Capsule.`);
866
+ }
867
+
868
+ if (replyTo.channel === channel.name) {
869
+ throw invalidConfig(`channels[${index}].replyTo.channel must not point back to the same channel.`);
870
+ }
871
+ }
872
+
873
+ function validateGenericWebhookOutputAuth(value: unknown, index: number): void {
874
+ if (!isRecord(value)) {
875
+ throw invalidConfig(`channels[${index}].auth must be an object for outbound webhook channels.`);
876
+ }
877
+
878
+ if (value.type === "none") {
879
+ return;
880
+ }
881
+
882
+ if (value.type === "bearer") {
883
+ expectString(value.tokenSecret, `channels[${index}].auth.tokenSecret`);
884
+ validateSecretNames([value.tokenSecret], `channels[${index}].auth.tokenSecret`);
885
+ return;
886
+ }
887
+
888
+ if (value.type === "header") {
889
+ expectString(value.headerName, `channels[${index}].auth.headerName`);
890
+
891
+ if (!HTTP_HEADER_NAME_PATTERN.test(value.headerName)) {
892
+ throw invalidConfig(`channels[${index}].auth.headerName must be an HTTP header name.`);
893
+ }
894
+
895
+ expectString(value.valueSecret, `channels[${index}].auth.valueSecret`);
896
+ validateSecretNames([value.valueSecret], `channels[${index}].auth.valueSecret`);
897
+ return;
898
+ }
899
+
900
+ if (value.type === "hmac") {
901
+ if (value.headerName !== undefined) {
902
+ expectString(value.headerName, `channels[${index}].auth.headerName`);
903
+
904
+ if (!HTTP_HEADER_NAME_PATTERN.test(value.headerName)) {
905
+ throw invalidConfig(`channels[${index}].auth.headerName must be an HTTP header name.`);
906
+ }
907
+ }
908
+
909
+ expectString(value.secret, `channels[${index}].auth.secret`);
910
+ validateSecretNames([value.secret], `channels[${index}].auth.secret`);
911
+ return;
912
+ }
913
+
914
+ throw invalidConfig(`channels[${index}].auth.type must be "none", "bearer", "header", or "hmac".`);
915
+ }
916
+
917
+ function genericWebhookOutputAuthSecrets(value: unknown): string[] {
918
+ if (!isRecord(value)) {
919
+ return [];
920
+ }
921
+
922
+ if (value.type === "bearer" && typeof value.tokenSecret === "string") {
923
+ return [value.tokenSecret];
924
+ }
925
+
926
+ if (value.type === "header" && typeof value.valueSecret === "string") {
927
+ return [value.valueSecret];
928
+ }
929
+
930
+ if (value.type === "hmac" && typeof value.secret === "string") {
931
+ return [value.secret];
932
+ }
933
+
934
+ return [];
935
+ }
936
+
793
937
  function validateChannelLimits(value: unknown, index: number): void {
794
938
  if (value === undefined) {
795
939
  return;
@@ -564,11 +564,23 @@ async function handlePortableChannel(
564
564
  conversationId,
565
565
  signal: request.signal,
566
566
  });
567
+ const routedReply = channel.replyTo
568
+ ? await sendDevChannelReply({
569
+ capsule,
570
+ sourceChannel: channel,
571
+ sourceEvent: event,
572
+ rawBody,
573
+ conversationId: result.conversationId,
574
+ message: result.message.content,
575
+ })
576
+ : null;
577
+
567
578
  deliveries.push({
568
579
  state: "sent",
569
580
  dedupeKey: event.dedupeKey,
570
581
  conversationId: result.conversationId,
571
582
  message: result.message,
583
+ ...(routedReply ? { outbound: routedReply } : {}),
572
584
  });
573
585
  }
574
586
 
@@ -579,6 +591,118 @@ async function handlePortableChannel(
579
591
  return jsonResponse({ deliveries });
580
592
  }
581
593
 
594
+ async function sendDevChannelReply(input: {
595
+ capsule: LoadedAgentCapsule;
596
+ sourceChannel: AgentChannel;
597
+ sourceEvent: Awaited<ReturnType<ChannelAdapter["normalizeInbound"]>>[number];
598
+ rawBody: string;
599
+ conversationId: string;
600
+ message: string;
601
+ }) {
602
+ const replyTo = input.sourceChannel.replyTo;
603
+
604
+ if (!replyTo) {
605
+ return null;
606
+ }
607
+
608
+ const target = (input.capsule.config.channels ?? []).find((channel) => channel.name === replyTo.channel);
609
+
610
+ if (!target) {
611
+ throw new AgentKitError("channel_not_found", `Reply target channel "${replyTo.channel}" was not found.`);
612
+ }
613
+
614
+ if (target.type === "webhook" && target.direction !== "outbound") {
615
+ throw new AgentKitError("channel_payload_invalid", `Reply target channel "${replyTo.channel}" is inbound-only.`);
616
+ }
617
+
618
+ const recipient = replyTo.recipientFrom ? readDevJsonPathScalar(input.rawBody, replyTo.recipientFrom) : null;
619
+
620
+ if (replyTo.recipientFrom && !recipient) {
621
+ throw new AgentKitError("channel_payload_invalid", `Reply route recipientFrom "${replyTo.recipientFrom}" did not resolve to a non-empty value.`);
622
+ }
623
+
624
+ const externalIdentity = recipient
625
+ ? recipientIdentityForDevChannel(target, recipient)
626
+ : input.sourceEvent.externalIdentity.key;
627
+
628
+ const targetAdapter = adapterForChannel(target.type, target.provider);
629
+ const secrets = await resolveChannelSecrets(input.capsule.root, target);
630
+
631
+ return targetAdapter.sendMessage({
632
+ type: target.type,
633
+ provider: target.provider,
634
+ channelId: target.name,
635
+ channel: target,
636
+ conversationId: input.conversationId,
637
+ externalIdentity: {
638
+ key: externalIdentity,
639
+ },
640
+ message: {
641
+ role: "assistant",
642
+ contentType: "text",
643
+ content: input.message,
644
+ },
645
+ secrets,
646
+ });
647
+ }
648
+
649
+ function recipientIdentityForDevChannel(channel: AgentChannel, recipient: string): string {
650
+ if (channel.type === "whatsapp") {
651
+ const prefix = `whatsapp:${channel.provider}:contact:`;
652
+ return recipient.startsWith(prefix) ? recipient : `${prefix}${recipient}`;
653
+ }
654
+
655
+ if (channel.type === "telegram") {
656
+ return recipient.includes(":chat:") ? recipient : `telegram:reply:chat:${recipient}`;
657
+ }
658
+
659
+ if (channel.type === "webhook" && channel.direction === "outbound") {
660
+ return recipient.startsWith("webhook:generic:") ? recipient : `webhook:generic:recipient:${recipient}`;
661
+ }
662
+
663
+ if (channel.type === "website") {
664
+ return recipient.startsWith("website:") ? recipient : `website:reply:${createHash("sha256").update(recipient).digest("hex").slice(0, 32)}`;
665
+ }
666
+
667
+ throw new AgentKitError(
668
+ "channel_payload_invalid",
669
+ `Reply target ${channel.type}/${channel.provider} requires a provider-native reply target and cannot use recipientFrom.`,
670
+ );
671
+ }
672
+
673
+ function readDevJsonPathScalar(rawBody: string, path: string): string | undefined {
674
+ let value: unknown;
675
+
676
+ try {
677
+ value = JSON.parse(rawBody) as unknown;
678
+ } catch {
679
+ return undefined;
680
+ }
681
+
682
+ for (const segment of path.replace(/^payload\./, "").split(".")) {
683
+ if (!isRecord(value)) {
684
+ return undefined;
685
+ }
686
+
687
+ value = value[segment];
688
+ }
689
+
690
+ if (typeof value === "string") {
691
+ const trimmed = value.trim();
692
+ return trimmed.length > 0 ? trimmed : undefined;
693
+ }
694
+
695
+ if (typeof value === "number" && Number.isFinite(value)) {
696
+ return String(value);
697
+ }
698
+
699
+ if (typeof value === "boolean") {
700
+ return String(value);
701
+ }
702
+
703
+ return undefined;
704
+ }
705
+
582
706
  async function processDevChannelMessage(
583
707
  capsule: LoadedAgentCapsule,
584
708
  adapter: ChannelAdapter,
@@ -1218,7 +1342,13 @@ function discordLocalInteractionResponse(
1218
1342
 
1219
1343
  async function resolveChannelSecrets(root: string, channel: AgentChannel): Promise<Record<string, string>> {
1220
1344
  const env = await loadCapsuleEnv(root);
1221
- return Object.fromEntries(channel.secrets.map((name) => [name, env[name] ?? ""]));
1345
+ const secrets = Object.fromEntries(channel.secrets.map((name) => [name, env[name] ?? ""]));
1346
+
1347
+ if (env.AGENTKIT_CHANNEL_SEND_DRY_RUN === "1") {
1348
+ secrets.AGENTKIT_CHANNEL_SEND_DRY_RUN = "1";
1349
+ }
1350
+
1351
+ return secrets;
1222
1352
  }
1223
1353
 
1224
1354
  function relativePath(root: string, path: string): string {
@@ -101,9 +101,16 @@ export type AgentInspectChannel = {
101
101
  name: string;
102
102
  type: AgentChannel["type"];
103
103
  provider: AgentChannel["provider"];
104
+ direction?: Extract<AgentChannel, { type: "webhook" }>["direction"];
104
105
  secrets: string[];
105
106
  buffer?: AgentChannel["buffer"];
106
107
  audio?: AgentChannel["audio"];
108
+ replyTo?: Extract<AgentChannel, { replyTo?: unknown }>["replyTo"];
109
+ urlSecret?: string;
110
+ auth?: {
111
+ type: string;
112
+ headerName?: string;
113
+ };
107
114
  };
108
115
 
109
116
  export type AgentInspectSkills = {
@@ -209,9 +216,20 @@ export function buildInspectState(
209
216
  name: channel.name,
210
217
  type: channel.type,
211
218
  provider: channel.provider,
219
+ ...("direction" in channel && channel.direction ? { direction: channel.direction } : {}),
212
220
  secrets: [...channel.secrets],
213
221
  ...(channel.buffer ? { buffer: channel.buffer } : {}),
214
222
  ...(channel.audio ? { audio: channel.audio } : {}),
223
+ ...(channel.replyTo ? { replyTo: channel.replyTo } : {}),
224
+ ...("urlSecret" in channel && channel.urlSecret ? { urlSecret: channel.urlSecret } : {}),
225
+ ...("auth" in channel && channel.auth
226
+ ? {
227
+ auth: {
228
+ type: channel.auth.type,
229
+ ...("headerName" in channel.auth && channel.auth.headerName ? { headerName: channel.auth.headerName } : {}),
230
+ },
231
+ }
232
+ : {}),
215
233
  })),
216
234
  transcription: {
217
235
  enabled: transcription.enabled,
@@ -466,10 +466,32 @@ export function webhookChannel(input) {
466
466
  return {
467
467
  type: "webhook",
468
468
  provider: "generic",
469
+ direction: "inbound",
469
470
  name: input.name,
470
471
  secrets: normalizeChannelSecrets(input.secrets ?? ["AGENTKIT_WEBHOOK_SECRET"], "generic webhook channel"),
471
472
  ...(input.limits ? { limits: input.limits } : {}),
472
473
  ...(input.buffer ? { buffer: normalizeChannelBuffer(input.buffer, "generic webhook channel") } : {}),
474
+ ...(input.replyTo ? { replyTo: normalizeChannelReplyRoute(input.replyTo, "generic webhook channel") } : {}),
475
+ };
476
+ }
477
+
478
+ export function webhookOutputChannel(input) {
479
+ const auth = normalizeGenericWebhookOutputAuth(input.auth ?? { type: "none" }, "generic webhook output channel");
480
+ const requiredSecrets = [
481
+ input.urlSecret,
482
+ ...genericWebhookOutputAuthSecrets(auth),
483
+ ...(input.secrets ?? []),
484
+ ];
485
+
486
+ return {
487
+ type: "webhook",
488
+ provider: "generic",
489
+ direction: "outbound",
490
+ name: input.name,
491
+ urlSecret: normalizeSecretName(input.urlSecret, "generic webhook output channel urlSecret"),
492
+ auth,
493
+ secrets: normalizeChannelSecrets(requiredSecrets, "generic webhook output channel"),
494
+ ...(input.limits ? { limits: input.limits } : {}),
473
495
  };
474
496
  }
475
497
 
@@ -493,14 +515,100 @@ function normalizeChannelSecrets(secrets, context) {
493
515
  const uniqueSecrets = new Set();
494
516
 
495
517
  for (const secret of secrets) {
496
- if (!/^[A-Z_][A-Z0-9_]*$/.test(secret)) {
497
- throw new Error(\`\${context} secrets must be environment variable names, not secret values.\`);
518
+ uniqueSecrets.add(normalizeSecretName(secret, \`\${context} secrets\`));
519
+ }
520
+
521
+ return Array.from(uniqueSecrets).sort();
522
+ }
523
+
524
+ function normalizeSecretName(secret, context) {
525
+ if (!/^[A-Z_][A-Z0-9_]*$/.test(secret)) {
526
+ if (context.endsWith("secrets")) {
527
+ throw new Error(\`\${context} must be environment variable names, not secret values.\`);
498
528
  }
499
529
 
500
- uniqueSecrets.add(secret);
530
+ throw new Error(\`\${context} must be an environment variable name, not a secret value.\`);
501
531
  }
502
532
 
503
- return Array.from(uniqueSecrets).sort();
533
+ return secret;
534
+ }
535
+
536
+ function normalizeChannelReplyRoute(input, context) {
537
+ const channel = input.channel.trim();
538
+
539
+ if (!/^[a-z][a-z0-9-]*$/.test(channel)) {
540
+ throw new Error(\`\${context} replyTo.channel must start with a lowercase letter and contain only lowercase letters, numbers, and hyphens.\`);
541
+ }
542
+
543
+ return {
544
+ channel,
545
+ ...(input.recipientFrom ? { recipientFrom: normalizeJsonPath(input.recipientFrom, \`\${context} replyTo.recipientFrom\`) } : {}),
546
+ };
547
+ }
548
+
549
+ function normalizeJsonPath(value, context) {
550
+ const path = value.trim().replace(/^payload\\./, "");
551
+
552
+ if (!/^[A-Za-z_][A-Za-z0-9_]*(?:\\.[A-Za-z_][A-Za-z0-9_]*)*$/.test(path)) {
553
+ throw new Error(\`\${context} must be a dotted JSON field path such as "phone" or "customer.phone".\`);
554
+ }
555
+
556
+ return path;
557
+ }
558
+
559
+ function normalizeGenericWebhookOutputAuth(auth, context) {
560
+ if (auth.type === "none") {
561
+ return { type: "none" };
562
+ }
563
+
564
+ if (auth.type === "bearer") {
565
+ return {
566
+ type: "bearer",
567
+ tokenSecret: normalizeSecretName(auth.tokenSecret, \`\${context} auth.tokenSecret\`),
568
+ };
569
+ }
570
+
571
+ if (auth.type === "header") {
572
+ if (!/^[A-Za-z][A-Za-z0-9-]*$/.test(auth.headerName)) {
573
+ throw new Error(\`\${context} auth.headerName must be an HTTP header name.\`);
574
+ }
575
+
576
+ return {
577
+ type: "header",
578
+ headerName: auth.headerName,
579
+ valueSecret: normalizeSecretName(auth.valueSecret, \`\${context} auth.valueSecret\`),
580
+ };
581
+ }
582
+
583
+ if (auth.type === "hmac") {
584
+ if (auth.headerName !== undefined && !/^[A-Za-z][A-Za-z0-9-]*$/.test(auth.headerName)) {
585
+ throw new Error(\`\${context} auth.headerName must be an HTTP header name.\`);
586
+ }
587
+
588
+ return {
589
+ type: "hmac",
590
+ secret: normalizeSecretName(auth.secret, \`\${context} auth.secret\`),
591
+ ...(auth.headerName ? { headerName: auth.headerName } : {}),
592
+ };
593
+ }
594
+
595
+ throw new Error(\`\${context} auth.type must be "none", "bearer", "header", or "hmac".\`);
596
+ }
597
+
598
+ function genericWebhookOutputAuthSecrets(auth) {
599
+ if (auth.type === "bearer") {
600
+ return [auth.tokenSecret];
601
+ }
602
+
603
+ if (auth.type === "header") {
604
+ return [auth.valueSecret];
605
+ }
606
+
607
+ if (auth.type === "hmac") {
608
+ return [auth.secret];
609
+ }
610
+
611
+ return [];
504
612
  }
505
613
 
506
614
  function normalizeIntegrationName(value, context) {