talon-agent 3.24.1 → 3.25.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 (35) hide show
  1. package/package.json +4 -1
  2. package/prompts/whatsapp.md +58 -0
  3. package/src/backend/shared/delivery-contract.ts +1 -0
  4. package/src/core/doctor.ts +5 -0
  5. package/src/core/frontend-runtime/builtins.ts +8 -0
  6. package/src/core/mcp-hub/talon-server.ts +1 -0
  7. package/src/core/prompt/embedded-prompts.ts +2 -0
  8. package/src/core/tools/chat.ts +4 -4
  9. package/src/core/tools/history.ts +5 -5
  10. package/src/core/tools/media.ts +1 -1
  11. package/src/core/tools/members.ts +4 -4
  12. package/src/core/tools/messaging.ts +12 -11
  13. package/src/core/tools/moderation.ts +2 -2
  14. package/src/core/tools/scheduling.ts +2 -2
  15. package/src/core/tools/types.ts +1 -1
  16. package/src/frontend/factories.ts +1 -0
  17. package/src/frontend/whatsapp/actions/chat-info.ts +228 -0
  18. package/src/frontend/whatsapp/actions/index.ts +78 -0
  19. package/src/frontend/whatsapp/actions/media.ts +248 -0
  20. package/src/frontend/whatsapp/actions/messaging.ts +281 -0
  21. package/src/frontend/whatsapp/actions/moderation.ts +231 -0
  22. package/src/frontend/whatsapp/actions/shared.ts +196 -0
  23. package/src/frontend/whatsapp/actions/types.ts +34 -0
  24. package/src/frontend/whatsapp/factory.ts +12 -0
  25. package/src/frontend/whatsapp/formatting.ts +172 -0
  26. package/src/frontend/whatsapp/identity.ts +150 -0
  27. package/src/frontend/whatsapp/index.ts +553 -0
  28. package/src/frontend/whatsapp/media-store.ts +97 -0
  29. package/src/frontend/whatsapp/message-store.ts +140 -0
  30. package/src/frontend/whatsapp/pins.ts +52 -0
  31. package/src/frontend/whatsapp/registry.ts +92 -0
  32. package/src/util/chat-id.ts +5 -0
  33. package/src/util/config.ts +43 -0
  34. package/src/util/log.ts +1 -0
  35. package/src/util/paths.ts +2 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "3.24.1",
3
+ "version": "3.25.0",
4
4
  "description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
5
5
  "author": "Dylan Neve",
6
6
  "license": "MIT",
@@ -110,6 +110,7 @@
110
110
  "@opencode-ai/sdk": "^1.17.4",
111
111
  "@playwright/mcp": "0.0.79",
112
112
  "@types/cross-spawn": "^6.0.6",
113
+ "baileys": "^7.0.0-rc14",
113
114
  "big-integer": "^1.6.52",
114
115
  "cheerio": "^1.2.0",
115
116
  "croner": "^10.0.1",
@@ -125,6 +126,7 @@
125
126
  "picocolors": "^1.1.1",
126
127
  "pino": "^10.3.1",
127
128
  "pino-pretty": "^13.1.3",
129
+ "qrcode-terminal": "^0.12.0",
128
130
  "telegram": "^2.26.22",
129
131
  "tsx": "^4.21.0",
130
132
  "undici": "^8.0.2",
@@ -135,6 +137,7 @@
135
137
  },
136
138
  "devDependencies": {
137
139
  "@types/node": "^26.0.0",
140
+ "@types/qrcode-terminal": "^0.12.2",
138
141
  "@types/write-file-atomic": "^4.0.3",
139
142
  "@vitest/coverage-v8": "^4.1.3",
140
143
  "dependency-cruiser": "^18.1.0",
@@ -0,0 +1,58 @@
1
+ ## WhatsApp Mode
2
+
3
+ You are running on a personal WhatsApp account (multi-device link).
4
+ Messages arrive as `[SenderName] msg_id:<id>: text`. Use names naturally,
5
+ and use that `msg_id` when a tool asks for a message id — reactions,
6
+ replies, edits, deletes and pins all address messages that way.
7
+
8
+ How replies are delivered (end_turn / send_message and what counts as a
9
+ valid turn) is defined in the **Response flow** section at the end of
10
+ these instructions — that contract wins over anything here.
11
+
12
+ ### Formatting
13
+
14
+ WhatsApp has its own markup, and Talon converts standard Markdown into it
15
+ for you. Write normal Markdown; these are the shapes that survive:
16
+
17
+ - `**bold**` → _bold_, `_italic_`, `~~strike~~` → ~~strike~~
18
+ - `` `inline code` `` and fenced code blocks
19
+ - Bullet and numbered lists, block quotes
20
+ - Headings become bold lines — WhatsApp has no heading sizes
21
+ - Links render as `label (https://url)`; bare URLs auto-link
22
+ - Tables become an aligned monospace block; keep them narrow
23
+
24
+ Long messages are split across bubbles automatically at 4096 characters.
25
+
26
+ ### What works here
27
+
28
+ Text, replies, reactions, edits, deletes, forwards, pins, and typing
29
+ indicators. Media: photos, video, GIFs, voice notes, audio, documents,
30
+ stickers (.webp), round video notes, and albums. Also polls, locations,
31
+ venues, and contact cards. In groups you can read metadata, list members
32
+ and admins, change the subject and description, manage invite links,
33
+ handle join requests, and add/remove/promote/demote members when you are
34
+ an admin.
35
+
36
+ Inbound media is downloaded to the workspace before your turn starts —
37
+ the file path is in the message, so you can read it immediately.
38
+
39
+ ### What WhatsApp cannot do
40
+
41
+ - No interactive buttons on a personal account — button rows are rendered
42
+ as a numbered list, and people reply with the number.
43
+ - No ban list: removing someone from a group is the only eviction, and
44
+ they can rejoin with an invite link.
45
+ - No per-member mute — a group is either open or admins-only.
46
+ - No forum topics, no custom admin titles, no stopping a poll.
47
+ - Pins expire (24h, 7d, or 30d) and only pins Talon placed are listable.
48
+
49
+ ### Staying silent
50
+
51
+ Reactions work here, so a react is the light acknowledgement when a
52
+ message needs no reply; otherwise close the turn silently as the contract
53
+ describes.
54
+
55
+ ### Style
56
+
57
+ Concise. This is a phone chat — short paragraphs, no filler, no walls of
58
+ text. Match the sender's register.
@@ -67,6 +67,7 @@ const FRONTEND_TOOLS: Record<string, DeliveryToolNames> = {
67
67
  discord: { endTurn: "end_turn", send: "send", react: "react" },
68
68
  teams: { endTurn: "end_turn", send: "send_message" },
69
69
  native: { endTurn: "end_turn", send: "send_message", react: "react" },
70
+ whatsapp: { endTurn: "end_turn", send: "send_message" },
70
71
  };
71
72
 
72
73
  const DEFAULT_TOOLS: DeliveryToolNames = {
@@ -73,6 +73,7 @@ export interface DoctorConfigSlice {
73
73
  botToken?: string;
74
74
  teamsWebhookUrl?: string;
75
75
  discord?: { botToken?: string };
76
+ whatsapp?: object;
76
77
  claudeBinary?: string;
77
78
  codexApiKey?: string;
78
79
  openaiApiKey?: string;
@@ -128,6 +129,10 @@ function unconfiguredFrontends(config: DoctorConfigSlice): string[] {
128
129
  if (fe === "telegram") return !config.botToken;
129
130
  if (fe === "teams") return !config.teamsWebhookUrl;
130
131
  if (fe === "discord") return !config.discord?.botToken;
132
+ // WhatsApp needs no stored credential — pairing is interactive (QR /
133
+ // pairing code at first start) — but the config block must exist so
134
+ // the JID allowlists were consciously set.
135
+ if (fe === "whatsapp") return !config.whatsapp;
131
136
  return fe !== "terminal" && fe !== "native";
132
137
  });
133
138
  }
@@ -24,6 +24,7 @@ import {
24
24
  isTeamsChatId,
25
25
  isTelegramChatId,
26
26
  isTerminalChatId,
27
+ isWhatsAppChatId,
27
28
  } from "../../util/chat-id.js";
28
29
 
29
30
  function registerBuiltinFrontends(): void {
@@ -56,6 +57,13 @@ function registerBuiltinFrontends(): void {
56
57
  routePriority: 40,
57
58
  messaging: true,
58
59
  });
60
+ registerFrontend({
61
+ id: "whatsapp",
62
+ label: "WhatsApp",
63
+ ownsChatId: isWhatsAppChatId,
64
+ routePriority: 50,
65
+ messaging: true,
66
+ });
59
67
  registerFrontend({
60
68
  id: "telegram",
61
69
  label: "Telegram",
@@ -25,6 +25,7 @@ export const VALID_TOOL_FRONTENDS: ReadonlySet<string> = new Set([
25
25
  "terminal",
26
26
  "discord",
27
27
  "native",
28
+ "whatsapp",
28
29
  ]);
29
30
 
30
31
  export type TalonServerOptions = {
@@ -35,6 +35,7 @@ import asset21 from "../../../prompts/system/workspace.md" with { type: "file" }
35
35
  import asset22 from "../../../prompts/teams.md" with { type: "file" };
36
36
  import asset23 from "../../../prompts/telegram.md" with { type: "file" };
37
37
  import asset24 from "../../../prompts/terminal.md" with { type: "file" };
38
+ import asset25 from "../../../prompts/whatsapp.md" with { type: "file" };
38
39
 
39
40
  /** rel path (posix, under prompts/) → embedded file path (/$bunfs/… when compiled). */
40
41
  const ASSETS: Record<string, string> = {
@@ -63,6 +64,7 @@ const ASSETS: Record<string, string> = {
63
64
  "teams.md": asset22,
64
65
  "telegram.md": asset23,
65
66
  "terminal.md": asset24,
67
+ "whatsapp.md": asset25,
66
68
  };
67
69
 
68
70
  /** Read an embedded prompt by its rel path (e.g. "system/cron.md"). */
@@ -19,7 +19,7 @@ export const chatTools: ToolDefinition[] = [
19
19
  description: "List chat administrators.",
20
20
  schema: {},
21
21
  execute: (_params, bridge) => bridge("get_chat_admins", {}),
22
- frontends: ["telegram", "discord"],
22
+ frontends: ["telegram", "discord", "whatsapp"],
23
23
  tag: "chat",
24
24
  },
25
25
 
@@ -28,7 +28,7 @@ export const chatTools: ToolDefinition[] = [
28
28
  description: "Get total member count.",
29
29
  schema: {},
30
30
  execute: (_params, bridge) => bridge("get_chat_member_count", {}),
31
- frontends: ["telegram", "discord"],
31
+ frontends: ["telegram", "discord", "whatsapp"],
32
32
  tag: "chat",
33
33
  },
34
34
 
@@ -37,7 +37,7 @@ export const chatTools: ToolDefinition[] = [
37
37
  description: "Change chat title (admin).",
38
38
  schema: { title: z.string() },
39
39
  execute: (params, bridge) => bridge("set_chat_title", params),
40
- frontends: ["telegram", "discord"],
40
+ frontends: ["telegram", "discord", "whatsapp"],
41
41
  tag: "chat",
42
42
  },
43
43
 
@@ -46,7 +46,7 @@ export const chatTools: ToolDefinition[] = [
46
46
  description: "Change chat description (admin).",
47
47
  schema: { description: z.string() },
48
48
  execute: (params, bridge) => bridge("set_chat_description", params),
49
- frontends: ["telegram", "discord"],
49
+ frontends: ["telegram", "discord", "whatsapp"],
50
50
  tag: "chat",
51
51
  },
52
52
  ];
@@ -30,7 +30,7 @@ export const historyTools: ToolDefinition[] = [
30
30
  before: params.before,
31
31
  offset_id: params.offset_id,
32
32
  }),
33
- frontends: ["telegram", "discord"],
33
+ frontends: ["telegram", "discord", "whatsapp"],
34
34
  tag: "history",
35
35
  },
36
36
 
@@ -42,7 +42,7 @@ export const historyTools: ToolDefinition[] = [
42
42
  limit: z.number().optional(),
43
43
  },
44
44
  execute: (params, bridge) => bridge("search_history", params),
45
- frontends: ["telegram", "discord"],
45
+ frontends: ["telegram", "discord", "whatsapp"],
46
46
  tag: "history",
47
47
  },
48
48
 
@@ -54,7 +54,7 @@ export const historyTools: ToolDefinition[] = [
54
54
  limit: z.number().optional(),
55
55
  },
56
56
  execute: (params, bridge) => bridge("get_user_messages", params),
57
- frontends: ["telegram", "discord"],
57
+ frontends: ["telegram", "discord", "whatsapp"],
58
58
  tag: "history",
59
59
  },
60
60
 
@@ -63,7 +63,7 @@ export const historyTools: ToolDefinition[] = [
63
63
  description: "Get a specific message by ID.",
64
64
  schema: { message_id: snowflakeOrIdSchema },
65
65
  execute: (params, bridge) => bridge("get_message_by_id", params),
66
- frontends: ["telegram", "discord"],
66
+ frontends: ["telegram", "discord", "whatsapp"],
67
67
  tag: "history",
68
68
  },
69
69
 
@@ -77,7 +77,7 @@ export const historyTools: ToolDefinition[] = [
77
77
  ),
78
78
  },
79
79
  execute: (params, bridge) => bridge("download_media", params),
80
- frontends: ["telegram", "discord"],
80
+ frontends: ["telegram", "discord", "whatsapp"],
81
81
  tag: "history",
82
82
  },
83
83
  ];
@@ -17,7 +17,7 @@ export const mediaTools: ToolDefinition[] = [
17
17
  .describe("Number of entries (default 10, max 20)"),
18
18
  },
19
19
  execute: (params, bridge) => bridge("list_media", { limit: params.limit }),
20
- frontends: ["telegram", "discord"],
20
+ frontends: ["telegram", "discord", "whatsapp"],
21
21
  tag: "media",
22
22
  },
23
23
  ];
@@ -13,7 +13,7 @@ export const memberTools: ToolDefinition[] = [
13
13
  schema: { limit: z.number().optional() },
14
14
  execute: (params, bridge) =>
15
15
  bridge("list_known_users", { limit: params.limit }),
16
- frontends: ["telegram", "discord"],
16
+ frontends: ["telegram", "discord", "whatsapp"],
17
17
  tag: "members",
18
18
  },
19
19
 
@@ -22,7 +22,7 @@ export const memberTools: ToolDefinition[] = [
22
22
  description: "Get detailed info about a user by ID.",
23
23
  schema: { user_id: snowflakeOrIdSchema },
24
24
  execute: (params, bridge) => bridge("get_member_info", params),
25
- frontends: ["telegram", "discord"],
25
+ frontends: ["telegram", "discord", "whatsapp"],
26
26
  tag: "members",
27
27
  },
28
28
 
@@ -32,7 +32,7 @@ export const memberTools: ToolDefinition[] = [
32
32
  "Get how many members are currently online or recently active.",
33
33
  schema: {},
34
34
  execute: (_params, bridge) => bridge("online_count", {}),
35
- frontends: ["telegram", "discord"],
35
+ frontends: ["telegram", "discord", "whatsapp"],
36
36
  tag: "members",
37
37
  },
38
38
 
@@ -41,7 +41,7 @@ export const memberTools: ToolDefinition[] = [
41
41
  description: "Get all pinned messages in the current chat.",
42
42
  schema: {},
43
43
  execute: (_params, bridge) => bridge("get_pinned_messages", {}),
44
- frontends: ["telegram", "discord"],
44
+ frontends: ["telegram", "discord", "whatsapp"],
45
45
  tag: "members",
46
46
  },
47
47
  ];
@@ -110,7 +110,7 @@ Notes:
110
110
  // src/backend/claude-sdk/options.ts:turnTerminatorHook.
111
111
  return throwIfFailed(result, "end_turn");
112
112
  },
113
- frontends: ["telegram", "teams", "discord", "native"],
113
+ frontends: ["telegram", "teams", "discord", "native", "whatsapp"],
114
114
  tag: "messaging",
115
115
  endsTurn: true,
116
116
  delivery: true,
@@ -473,7 +473,7 @@ Examples:
473
473
  return { ok: false, error: `Unknown type: ${type}` };
474
474
  }
475
475
  },
476
- frontends: ["telegram", "discord"],
476
+ frontends: ["telegram", "discord", "whatsapp"],
477
477
  tag: "messaging",
478
478
  delivery: true,
479
479
  },
@@ -490,7 +490,7 @@ Examples:
490
490
  text: z.string().describe("Message text. Supports Markdown."),
491
491
  },
492
492
  execute: (params, bridge) => bridge("send_message", params),
493
- frontends: ["teams", "native"],
493
+ frontends: ["teams", "native", "whatsapp"],
494
494
  tag: "messaging",
495
495
  delivery: true,
496
496
  },
@@ -515,7 +515,7 @@ Example: send_message_with_buttons(text="Choose:", rows=[[{"text":"Docs","url":"
515
515
  .describe("Button rows"),
516
516
  },
517
517
  execute: (params, bridge) => bridge("send_message_with_buttons", params),
518
- frontends: ["teams", "native"],
518
+ frontends: ["teams", "native", "whatsapp"],
519
519
  tag: "messaging",
520
520
  delivery: true,
521
521
  },
@@ -572,7 +572,7 @@ Valid emoji: 👍 👎 ❤ 🔥 🥰 👏 😁 🤔 🤯 😱 🤬 😢 🎉
572
572
  const result = await bridge("react", rest);
573
573
  return throwIfFailed(result, "react");
574
574
  },
575
- frontends: ["telegram", "discord", "native"],
575
+ frontends: ["telegram", "discord", "native", "whatsapp"],
576
576
  tag: "messaging",
577
577
  endsTurn: true,
578
578
  delivery: true,
@@ -592,7 +592,7 @@ Valid emoji: 👍 👎 ❤ 🔥 🥰 👏 😁 🤔 🤯 😱 🤬 😢 🎉
592
592
  .describe("Edit the media caption rather than message text (Telegram)"),
593
593
  },
594
594
  execute: (params, bridge) => bridge("edit_message", params),
595
- frontends: ["telegram", "discord", "native"],
595
+ frontends: ["telegram", "discord", "native", "whatsapp"],
596
596
  tag: "messaging",
597
597
  },
598
598
 
@@ -609,7 +609,7 @@ Valid emoji: 👍 👎 ❤ 🔥 🥰 👏 😁 🤔 🤯 😱 🤬 😢 🎉
609
609
  .describe("Bulk delete these message IDs (Telegram)"),
610
610
  },
611
611
  execute: (params, bridge) => bridge("delete_message", params),
612
- frontends: ["telegram", "discord", "native"],
612
+ frontends: ["telegram", "discord", "native", "whatsapp"],
613
613
  tag: "messaging",
614
614
  },
615
615
 
@@ -632,7 +632,7 @@ Valid emoji: 👍 👎 ❤ 🔥 🥰 👏 😁 🤔 🤯 😱 🤬 😢 🎉
632
632
  .describe("Destination chat (default: current chat)"),
633
633
  },
634
634
  execute: (params, bridge) => bridge("forward_message", params),
635
- frontends: ["telegram", "discord"],
635
+ frontends: ["telegram", "discord", "whatsapp"],
636
636
  tag: "messaging",
637
637
  },
638
638
 
@@ -648,7 +648,7 @@ Valid emoji: 👍 👎 ❤ 🔥 🥰 👏 😁 🤔 🤯 😱 🤬 😢 🎉
648
648
  to_chat_id: chatIdSchema.optional(),
649
649
  },
650
650
  execute: (params, bridge) => bridge("copy_message", params),
651
- frontends: ["telegram"],
651
+ frontends: ["telegram", "whatsapp"],
652
652
  tag: "messaging",
653
653
  },
654
654
 
@@ -658,7 +658,7 @@ Valid emoji: 👍 👎 ❤ 🔥 🥰 👏 😁 🤔 🤯 😱 🤬 😢 🎉
658
658
  description: "Pin a message.",
659
659
  schema: { message_id: snowflakeOrIdSchema },
660
660
  execute: (params, bridge) => bridge("pin_message", params),
661
- frontends: ["telegram", "discord"],
661
+ frontends: ["telegram", "discord", "whatsapp"],
662
662
  tag: "messaging",
663
663
  },
664
664
 
@@ -668,7 +668,7 @@ Valid emoji: 👍 👎 ❤ 🔥 🥰 👏 😁 🤔 🤯 😱 🤬 😢 🎉
668
668
  description: "Unpin a message.",
669
669
  schema: { message_id: snowflakeOrIdSchema.optional() },
670
670
  execute: (params, bridge) => bridge("unpin_message", params),
671
- frontends: ["telegram", "discord"],
671
+ frontends: ["telegram", "discord", "whatsapp"],
672
672
  tag: "messaging",
673
673
  },
674
674
 
@@ -681,6 +681,7 @@ Valid emoji: 👍 👎 ❤ 🔥 🥰 👏 😁 🤔 🤯 😱 🤬 😢 🎉
681
681
  message_id: idSchema.describe("Message ID of the poll to stop"),
682
682
  },
683
683
  execute: (params, bridge) => bridge("stop_poll", params),
684
+ // No WhatsApp equivalent: its polls run until the message is deleted.
684
685
  frontends: ["telegram"],
685
686
  tag: "messaging",
686
687
  },
@@ -112,7 +112,7 @@ Forum topics (supergroups with topics):
112
112
  .describe("Target chat ID. Omit for the current chat."),
113
113
  },
114
114
  execute: (params, bridge) => bridge("moderate", params),
115
- frontends: ["telegram"],
115
+ frontends: ["telegram", "whatsapp"],
116
116
  tag: "moderation",
117
117
  },
118
118
 
@@ -125,7 +125,7 @@ Forum topics (supergroups with topics):
125
125
  limit: z.number().optional().describe("Max photos to return (default 5)"),
126
126
  },
127
127
  execute: (params, bridge) => bridge("get_user_profile_photos", params),
128
- frontends: ["telegram"],
128
+ frontends: ["telegram", "whatsapp"],
129
129
  tag: "moderation",
130
130
  },
131
131
  ];
@@ -11,7 +11,7 @@ export const schedulingTools: ToolDefinition[] = [
11
11
  description: "Cancel a scheduled message.",
12
12
  schema: { schedule_id: z.string() },
13
13
  execute: (params, bridge) => bridge("cancel_scheduled", params),
14
- frontends: ["telegram", "discord"],
14
+ frontends: ["telegram", "discord", "whatsapp"],
15
15
  tag: "scheduling",
16
16
  },
17
17
 
@@ -21,7 +21,7 @@ export const schedulingTools: ToolDefinition[] = [
21
21
  "List pending scheduled messages in this chat (id, seconds until send, text). Scheduled messages persist across restarts.",
22
22
  schema: {},
23
23
  execute: (_params, bridge) => bridge("list_scheduled", {}),
24
- frontends: ["telegram", "discord"],
24
+ frontends: ["telegram", "discord", "whatsapp"],
25
25
  tag: "scheduling",
26
26
  },
27
27
 
@@ -9,7 +9,7 @@ import type { ZodRawShape } from "zod";
9
9
 
10
10
  /** Which frontends a tool is available on. "all" = every frontend. */
11
11
  export type ToolFrontend =
12
- "telegram" | "teams" | "terminal" | "discord" | "native" | "all";
12
+ "telegram" | "teams" | "terminal" | "discord" | "native" | "whatsapp" | "all";
13
13
 
14
14
  /** Domain tags for runtime filtering and grouping. */
15
15
  export type ToolTag =
@@ -12,4 +12,5 @@ import "./telegram/factory.js";
12
12
  import "./discord/factory.js";
13
13
  import "./teams/factory.js";
14
14
  import "./native/factory.js";
15
+ import "./whatsapp/factory.js";
15
16
  import "./terminal/factory.js";