@vellumai/assistant 0.12.2-dev.202609181913.1108ac3 → 0.12.2-dev.202609182015.188de8a

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/ARCHITECTURE.md CHANGED
@@ -300,7 +300,7 @@ The WhatsApp channel enables inbound and outbound messaging via the Meta WhatsAp
300
300
 
301
301
  **Egress** (daemon WhatsApp transport, `src/messaging/providers/whatsapp/`):
302
302
 
303
- 1. Replies, approval prompts, and proactive sends go out through the daemon's `whatsappTransport` (`transport.ts`), which calls the Meta Cloud API itself. `isDirectDelivery()` in `src/messaging/providers/index.ts` resolves the `/deliver/whatsapp` callback URL to this transport through `channelForCallback()` (`src/messaging/providers/callback-routing.ts`), so the daemon never posts back to the gateway or dials the URL's host and port. The exception is on the gateway side: its replies to invite and verification codes it intercepts at ingress (`deliverVerificationReply` in `gateway/src/verification/reply-delivery.ts`) still POST to the callback URL, which no gateway route serves, so those replies are not delivered.
303
+ 1. Replies, approval prompts, and proactive sends go out through the daemon's `whatsappTransport` (`transport.ts`), which calls the Meta Cloud API itself. `isDirectDelivery()` in `src/messaging/providers/index.ts` resolves the `/deliver/whatsapp` callback URL to this transport through `channelForCallback()` (`src/messaging/providers/callback-routing.ts`), so the daemon never posts back to the gateway or dials the URL's host and port. The gateway's own replies to invite and verification codes it intercepts at ingress reach this transport the same way: `deliverVerificationReply` (`gateway/src/verification/reply-delivery.ts`) hands the reply and the inbound message's callback URL to the daemon's IPC-only `deliver_gateway_reply` method (`src/ipc/routes/channel-reply-ipc-routes.ts`), which resolves the transport from that URL.
304
304
  2. `api.ts` reads `phone_number_id` and `access_token` from the secure store and sends through the Cloud API `/{phoneNumberId}/messages` endpoint.
305
305
  3. `send.ts` splits text into 4096-character chunks, preferring newline and then whitespace boundaries.
306
306
  4. An approval prompt renders as an interactive message with up to three reply buttons whose ids follow the shared `apr:<requestId>:<action>` convention. When the cap forces a cut, the reject and block actions are kept. The gateway normalizes the guardian's `button_reply` back into callback data on ingress.
@@ -24,13 +24,15 @@ export {
24
24
  PersistentIpcClient,
25
25
  } from "./ipc-client.js";
26
26
 
27
- // Outbound delivery contract (daemon → gateway) — Zod schemas + derived types
27
+ // Outbound delivery contract: Zod schemas + derived types
28
28
  export {
29
29
  ApprovalActionOptionSchema,
30
30
  ApprovalUIMetadataSchema,
31
31
  AttachmentMetadataSchema,
32
32
  ChannelDeliveryResultSchema,
33
33
  ChannelReplyPayloadSchema,
34
+ DELIVER_GATEWAY_REPLY_IPC_METHOD,
35
+ GatewayReplyRequestSchema,
34
36
  MessageAudienceSchema,
35
37
  PermissionRequestDetailsSchema,
36
38
  StreamOpSchema,
@@ -45,6 +47,7 @@ export type {
45
47
  AttachmentMetadata,
46
48
  ChannelDeliveryResult,
47
49
  ChannelReplyPayload,
50
+ GatewayReplyRequest,
48
51
  MessageAudience,
49
52
  PermissionRequestDetails,
50
53
  StreamOp,
@@ -1,13 +1,17 @@
1
1
  /**
2
- * Daemon → gateway outbound delivery contract.
2
+ * Outbound channel delivery contract.
3
3
  *
4
- * Zod schemas defining the wire format for channel replies delivered from
5
- * the daemon to the gateway via `POST /deliver/{channel}`. Both services
6
- * import from here so the contract is enforced at compile time.
4
+ * Zod schemas for a reply to a channel chat. The daemon constructs these
5
+ * payloads in `deliverChannelReply()` and `deliverApprovalPrompt()` and hands
6
+ * them to the channel transport its callback URL names
7
+ * (`messaging/providers`). `/deliver/{channel}` is only that callback URL's
8
+ * addressing form: no service serves it. A callback URL no
9
+ * transport owns (a managed callback carrying a `callback_token`) is POSTed
10
+ * over HTTP by `http-delivery.ts` instead.
7
11
  *
8
- * The daemon constructs these payloads in `deliverChannelReply()` and
9
- * `deliverApprovalPrompt()`; the gateway validates and dispatches them
10
- * to the target channel provider.
12
+ * The gateway sends through the same transports, never to a provider itself:
13
+ * a reply it composes for a message it answered at ingress goes to the daemon
14
+ * as a {@link GatewayReplyRequest}.
11
15
  */
12
16
 
13
17
  import type { KnownBlock } from "@slack/types";
@@ -279,6 +283,33 @@ export const ChannelReplyPayloadSchema = z.object({
279
283
 
280
284
  export type ChannelReplyPayload = z.infer<typeof ChannelReplyPayloadSchema>;
281
285
 
286
+ // ---------------------------------------------------------------------------
287
+ // Gateway reply: gateway to daemon
288
+ // ---------------------------------------------------------------------------
289
+
290
+ /**
291
+ * Daemon IPC method that delivers a {@link GatewayReplyRequest}. IPC-only:
292
+ * it has no HTTP route.
293
+ */
294
+ export const DELIVER_GATEWAY_REPLY_IPC_METHOD = "deliver_gateway_reply";
295
+
296
+ /**
297
+ * A text reply the gateway composed for an inbound message it answered
298
+ * itself (a verification code, an invite redemption), which the daemon
299
+ * delivers through the channel transport `callbackUrl` names. The callback
300
+ * URL is the one the inbound message carried, so the reply lands in the chat
301
+ * and thread the person wrote from.
302
+ */
303
+ export const GatewayReplyRequestSchema = ChannelReplyPayloadSchema.pick({
304
+ chatId: true,
305
+ assistantId: true,
306
+ }).extend({
307
+ callbackUrl: z.string().min(1),
308
+ text: z.string().min(1),
309
+ });
310
+
311
+ export type GatewayReplyRequest = z.infer<typeof GatewayReplyRequestSchema>;
312
+
282
313
  // ---------------------------------------------------------------------------
283
314
  // Channel delivery result — gateway response
284
315
  // ---------------------------------------------------------------------------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.12.2-dev.202609181913.1108ac3",
3
+ "version": "0.12.2-dev.202609182015.188de8a",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -662,7 +662,7 @@ describe("high/critical urgency channel force", () => {
662
662
  sourceChannel: "watcher" | "assistant_tool",
663
663
  ) {
664
664
  return emitNotificationSignal({
665
- sourceEventName: "watcher.escalation",
665
+ sourceEventName: "watcher.notification",
666
666
  sourceChannel,
667
667
  sourceContextId: "watch-1",
668
668
  routingIntent: "single_channel",
@@ -183,7 +183,7 @@ describe("TelegramAdapter", () => {
183
183
 
184
184
  await adapter.send(
185
185
  makePayload({
186
- sourceEventName: "watcher.escalation",
186
+ sourceEventName: "watcher.notification",
187
187
  copy: {
188
188
  title: " ",
189
189
  body: "",
@@ -191,7 +191,7 @@ describe("TelegramAdapter", () => {
191
191
  }),
192
192
  makeDestination(),
193
193
  );
194
- expect(sendCalls[2]?.text).toBe("watcher escalation");
194
+ expect(sendCalls[2]?.text).toBe("watcher notification");
195
195
  });
196
196
 
197
197
  // ── Access request inline keyboard tests ──────────────────────────────
@@ -175,7 +175,7 @@ describe("notifications send", () => {
175
175
  "--source-channel",
176
176
  "assistant_tool",
177
177
  "--source-event-name",
178
- "user.send_notification",
178
+ "schedule.notify",
179
179
  "--message",
180
180
  "Hello",
181
181
  ]);
@@ -186,7 +186,7 @@ describe("notifications send", () => {
186
186
 
187
187
  const body = lastSendBody();
188
188
  expect(body.sourceChannel).toBe("assistant_tool");
189
- expect(body.sourceEventName).toBe("user.send_notification");
189
+ expect(body.sourceEventName).toBe("schedule.notify");
190
190
  const payload = body.contextPayload as Record<string, unknown>;
191
191
  expect(payload.requestedMessage).toBe("Hello");
192
192
  });
@@ -221,7 +221,7 @@ describe("notifications send", () => {
221
221
  "--source-channel",
222
222
  "assistant_tool",
223
223
  "--source-event-name",
224
- "user.send_notification",
224
+ "schedule.notify",
225
225
  "--message",
226
226
  "Hello",
227
227
  "--preferred-channels",
@@ -241,7 +241,7 @@ describe("notifications send", () => {
241
241
  "--source-channel",
242
242
  "assistant_tool",
243
243
  "--source-event-name",
244
- "user.send_notification",
244
+ "schedule.notify",
245
245
  "--message",
246
246
  "Hello",
247
247
  "--urgency",
@@ -265,7 +265,7 @@ describe("notifications send", () => {
265
265
  "--source-channel",
266
266
  "water_reminder",
267
267
  "--source-event-name",
268
- "user.send_notification",
268
+ "schedule.notify",
269
269
  "--message",
270
270
  "Hello",
271
271
  ]);
@@ -300,7 +300,7 @@ describe("notifications send", () => {
300
300
  "--source-channel",
301
301
  "assistant_tool",
302
302
  "--source-event-name",
303
- "user.send_notification",
303
+ "schedule.notify",
304
304
  "--message",
305
305
  "Hi",
306
306
  "--conversation-id",
@@ -320,7 +320,7 @@ describe("notifications send", () => {
320
320
  "--source-channel",
321
321
  "assistant_tool",
322
322
  "--source-event-name",
323
- "user.send_notification",
323
+ "schedule.notify",
324
324
  "--message",
325
325
  "Hi",
326
326
  ]);
@@ -335,7 +335,7 @@ describe("notifications send", () => {
335
335
  "--source-channel",
336
336
  "assistant_tool",
337
337
  "--source-event-name",
338
- "user.send_notification",
338
+ "schedule.notify",
339
339
  "--message",
340
340
  "Hi",
341
341
  "--conversation-id",
@@ -359,7 +359,7 @@ describe("notifications send", () => {
359
359
  "--source-channel",
360
360
  "assistant_tool",
361
361
  "--source-event-name",
362
- "user.send_notification",
362
+ "schedule.notify",
363
363
  "--message",
364
364
  "Hello",
365
365
  ]);
@@ -384,7 +384,7 @@ describe("notifications send", () => {
384
384
  "--source-channel",
385
385
  "assistant_tool",
386
386
  "--source-event-name",
387
- "user.send_notification",
387
+ "schedule.notify",
388
388
  "--message",
389
389
  "Hello",
390
390
  ]);
@@ -59,6 +59,7 @@ import { RouteResponse } from "../runtime/routes/types.js";
59
59
  import { getLogger } from "../util/logger.js";
60
60
  import { mapGatewayIpcConnectError } from "./gateway-ipc-errors.js";
61
61
  import { ACTIVATION_SYNC_IPC_METHODS } from "./routes/activation-sync-ipc-routes.js";
62
+ import { CHANNEL_REPLY_IPC_METHODS } from "./routes/channel-reply-ipc-routes.js";
62
63
  import { CONTACTS_INFO_IPC_METHODS } from "./routes/contacts-info-ipc-routes.js";
63
64
  import { CONTACTS_MIRROR_IPC_METHODS } from "./routes/contacts-mirror-ipc-routes.js";
64
65
  import { CONVERSATION_SYNC_IPC_METHODS } from "./routes/conversation-sync-ipc-routes.js";
@@ -216,6 +217,7 @@ export class AssistantIpcServer {
216
217
  // never in ROUTES.
217
218
  for (const methodMap of [
218
219
  INVITE_IPC_METHODS,
220
+ CHANNEL_REPLY_IPC_METHODS,
219
221
  CONTACTS_INFO_IPC_METHODS,
220
222
  CONTACTS_MIRROR_IPC_METHODS,
221
223
  GUARDIAN_LABEL_IPC_METHODS,
@@ -0,0 +1,111 @@
1
+ /**
2
+ * The gateway's replies to messages it answered itself reach the person
3
+ * through the channel transport, the same one a turn's reply uses.
4
+ *
5
+ * The Bot API call is the boundary: everything between the gateway's request
6
+ * and Telegram's `sendMessage` is the real code, so the assertion is that the
7
+ * text a person would read left for their chat, in the topic they wrote from.
8
+ */
9
+
10
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
11
+
12
+ import { DELIVER_GATEWAY_REPLY_IPC_METHOD } from "@vellumai/gateway-client";
13
+
14
+ type CallTelegramBotApi =
15
+ typeof import("../../../messaging/providers/telegram-bot/api.js").callTelegramBotApi;
16
+
17
+ const callTelegramBotApiMock = mock<CallTelegramBotApi>(
18
+ async () => ({ message_id: 42 }) as never,
19
+ );
20
+
21
+ // Spread the actual module so exports this suite does not touch stay
22
+ // importable by files that share the bun process.
23
+ const actualTelegramApi =
24
+ await import("../../../messaging/providers/telegram-bot/api.js");
25
+ mock.module("../../../messaging/providers/telegram-bot/api.js", () => ({
26
+ ...actualTelegramApi,
27
+ callTelegramBotApi: (method: string, body: Record<string, unknown>) =>
28
+ callTelegramBotApiMock(method, body),
29
+ }));
30
+
31
+ const { CHANNEL_REPLY_IPC_METHODS } =
32
+ await import("../channel-reply-ipc-routes.js");
33
+ const { BadGatewayError, BadRequestError } =
34
+ await import("../../../runtime/routes/errors.js");
35
+
36
+ const deliverGatewayReply =
37
+ CHANNEL_REPLY_IPC_METHODS[DELIVER_GATEWAY_REPLY_IPC_METHOD]!;
38
+
39
+ // The callback URL exactly as the gateway's Telegram webhook builds it for a
40
+ // message sent in a topic.
41
+ const TELEGRAM_CALLBACK = "http://127.0.0.1:7830/deliver/telegram?threadId=7";
42
+
43
+ beforeEach(() => {
44
+ callTelegramBotApiMock.mockClear();
45
+ });
46
+
47
+ describe("deliver_gateway_reply", () => {
48
+ test("sends the reply to the person's Telegram chat, in their topic", async () => {
49
+ const result = await deliverGatewayReply({
50
+ body: {
51
+ callbackUrl: TELEGRAM_CALLBACK,
52
+ chatId: "12345",
53
+ text: "Welcome! You've been granted access.",
54
+ assistantId: "self",
55
+ },
56
+ });
57
+
58
+ const sends = callTelegramBotApiMock.mock.calls.filter(
59
+ ([method]) => method === "sendMessage",
60
+ );
61
+ expect(sends).toHaveLength(1);
62
+ expect(sends[0]![1]).toMatchObject({
63
+ chat_id: "12345",
64
+ text: "Welcome! You've been granted access.",
65
+ message_thread_id: 7,
66
+ });
67
+ expect(result).toEqual({ ok: true, messageIds: ["42"] });
68
+ });
69
+
70
+ test("answers a send the channel refuses with a status, not a bare error", async () => {
71
+ // Only a RouteError crosses IPC with a status; without one the gateway
72
+ // reads the refusal as a daemon that never answered.
73
+ callTelegramBotApiMock.mockImplementationOnce(async () => {
74
+ throw new Error("Bad Request: chat not found");
75
+ });
76
+
77
+ const rejection = expect(
78
+ deliverGatewayReply({
79
+ body: {
80
+ callbackUrl: TELEGRAM_CALLBACK,
81
+ chatId: "12345",
82
+ text: "Welcome! You've been granted access.",
83
+ },
84
+ }),
85
+ ).rejects;
86
+ await rejection.toBeInstanceOf(BadGatewayError);
87
+ await rejection.toMatchObject({ statusCode: 502 });
88
+ });
89
+
90
+ test("refuses a callback no channel transport owns, sending nothing", async () => {
91
+ await expect(
92
+ deliverGatewayReply({
93
+ body: {
94
+ callbackUrl: "http://127.0.0.1:7830/deliver/email",
95
+ chatId: "someone@example.com",
96
+ text: "Verification successful.",
97
+ },
98
+ }),
99
+ ).rejects.toBeInstanceOf(BadRequestError);
100
+ expect(callTelegramBotApiMock).not.toHaveBeenCalled();
101
+ });
102
+
103
+ test("refuses a request with no text, sending nothing", async () => {
104
+ await expect(
105
+ deliverGatewayReply({
106
+ body: { callbackUrl: TELEGRAM_CALLBACK, chatId: "12345", text: "" },
107
+ }),
108
+ ).rejects.toBeInstanceOf(BadRequestError);
109
+ expect(callTelegramBotApiMock).not.toHaveBeenCalled();
110
+ });
111
+ });
@@ -0,0 +1,73 @@
1
+ /**
2
+ * IPC-only channel reply method called by the gateway over the assistant IPC
3
+ * socket (`ipcCallAssistant`).
4
+ *
5
+ * The gateway answers some inbound messages itself without forwarding them
6
+ * (a verification code, an invite redemption), so the assistant never sees
7
+ * them. Its reply still has to reach the person, and the channel transports
8
+ * that can send it live here. This hands the reply to the transport the
9
+ * inbound message's callback URL names, which is how the daemon's own
10
+ * intercepts answer an inbound message: the gateway sends through the same
11
+ * transports and never to a provider itself.
12
+ *
13
+ * Nothing is recorded in a conversation. The message the reply answers never
14
+ * entered one, so a row for the reply would stand alone in a transcript that
15
+ * does not hold what it responds to.
16
+ */
17
+
18
+ import {
19
+ DELIVER_GATEWAY_REPLY_IPC_METHOD,
20
+ GatewayReplyRequestSchema,
21
+ } from "@vellumai/gateway-client";
22
+
23
+ import {
24
+ deliverDirect,
25
+ isDirectDelivery,
26
+ } from "../../messaging/providers/index.js";
27
+ import {
28
+ BadGatewayError,
29
+ BadRequestError,
30
+ } from "../../runtime/routes/errors.js";
31
+ import { parseBody } from "../../runtime/routes/parse-body.js";
32
+ import type { RouteHandlerArgs } from "../../runtime/routes/types.js";
33
+
34
+ /**
35
+ * Deliver a gateway-composed reply through the channel transport its callback
36
+ * URL names. Refuses a callback no transport owns rather than fetching it, so
37
+ * the gateway learns the channel cannot be answered this way.
38
+ *
39
+ * A send the channel refuses is answered as a `BadGatewayError`, as
40
+ * `channels/send` answers it: only a `RouteError` carries a status over IPC,
41
+ * and the gateway reads an error without one as a daemon that never answered.
42
+ */
43
+ export async function handleDeliverGatewayReply({
44
+ body = {},
45
+ }: RouteHandlerArgs) {
46
+ const { callbackUrl, chatId, text, assistantId } = parseBody(
47
+ GatewayReplyRequestSchema,
48
+ body,
49
+ );
50
+ if (!isDirectDelivery(callbackUrl)) {
51
+ throw new BadRequestError("No channel transport owns this callback URL");
52
+ }
53
+ try {
54
+ return await deliverDirect(callbackUrl, {
55
+ chatId,
56
+ text,
57
+ ...(assistantId ? { assistantId } : {}),
58
+ });
59
+ } catch (err) {
60
+ throw new BadGatewayError(err instanceof Error ? err.message : String(err));
61
+ }
62
+ }
63
+
64
+ /**
65
+ * IPC-only channel reply methods, keyed by IPC operationId. Registered
66
+ * directly on the assistant IPC server (see `assistant-server.ts`).
67
+ */
68
+ export const CHANNEL_REPLY_IPC_METHODS: Record<
69
+ string,
70
+ (args: RouteHandlerArgs) => unknown
71
+ > = {
72
+ [DELIVER_GATEWAY_REPLY_IPC_METHOD]: handleDeliverGatewayReply,
73
+ };
@@ -41,8 +41,9 @@ const DISCORD_ALLOWED_MENTIONS = { parse: ["users"] } as const;
41
41
 
42
42
  /**
43
43
  * Upper bound on an outbound attachment. Discord's real limit is the guild's
44
- * boost tier (10 MiB with no boosts, more above that), which the API does not
45
- * expose here, so the client-side guard is only the ceiling no tier exceeds:
44
+ * boost tier (20 MiB per file by default, more above that; see
45
+ * https://discord.com/developers/docs/reference#uploading-files), which the
46
+ * API does not expose here, so the client-side guard is only the ceiling no tier exceeds:
46
47
  * past it the upload is provably futile and not worth the bandwidth. Anything
47
48
  * under it is attempted and, if the guild's own tier rejects it, reported
48
49
  * through the same failure notice as any other attachment error.
@@ -692,7 +692,9 @@ describe("telegramTransport.streamReply", () => {
692
692
  });
693
693
 
694
694
  describe("sendTelegramAttachments", () => {
695
- // The Bot API's sendDocument upload cap; the sender skips anything larger.
695
+ // The Bot API's multipart upload limits: a photo, and any other file. The
696
+ // sender skips anything over the second.
697
+ const MAX_PHOTO_BYTES = 10 * 1024 * 1024;
696
698
  const MAX_ATTACHMENT_BYTES = 50 * 1024 * 1024;
697
699
 
698
700
  function attachment(
@@ -759,6 +761,33 @@ describe("sendTelegramAttachments", () => {
759
761
  expect(await document.text()).toBe("pdf");
760
762
  });
761
763
 
764
+ test("sends an image over the photo limit with sendDocument", async () => {
765
+ storeHolds({
766
+ "att-1": Buffer.alloc(MAX_PHOTO_BYTES),
767
+ "att-2": Buffer.alloc(MAX_PHOTO_BYTES + 1),
768
+ });
769
+
770
+ const result = await sendTelegramAttachments("123", [
771
+ attachment("att-1", "fits.png", "image/png", MAX_PHOTO_BYTES),
772
+ attachment("att-2", "scan.png", "image/png", MAX_PHOTO_BYTES + 1),
773
+ ]);
774
+
775
+ expect(multipartCalls().map((c) => c.method)).toEqual([
776
+ "sendPhoto",
777
+ "sendDocument",
778
+ ]);
779
+ const document = multipartCalls()[1]?.form.get("document") as File;
780
+ expect(document.name).toBe("scan.png");
781
+ expect(document.type).toBe("image/png");
782
+ expect(document.size).toBe(MAX_PHOTO_BYTES + 1);
783
+ expect(noticeTexts()).toEqual([]);
784
+ expect(result).toEqual({
785
+ allFailed: false,
786
+ failureCount: 0,
787
+ totalCount: 2,
788
+ });
789
+ });
790
+
762
791
  test("targets the topic on both the upload and the failure notice", async () => {
763
792
  storeHolds({ "att-1": Buffer.from("png") });
764
793
 
@@ -31,7 +31,9 @@ const TELEGRAM_MAX_MESSAGE_LEN = 4000;
31
31
  /** Telegram Bot API enforces a 1-64 byte limit on InlineKeyboardButton callback_data. */
32
32
  const TELEGRAM_MAX_CALLBACK_DATA_BYTES = 64;
33
33
 
34
- // Telegram Bot API sendDocument upload limit is 50 MB
34
+ // Bot API limits for a multipart upload: 10 MB for a photo, 50 MB for any
35
+ // other file (https://core.telegram.org/bots/api#sending-files).
36
+ const TELEGRAM_MAX_PHOTO_BYTES = 10 * 1024 * 1024;
35
37
  const TELEGRAM_MAX_ATTACHMENT_BYTES = 50 * 1024 * 1024;
36
38
 
37
39
  const TELEGRAM_IMAGE_MIME_PREFIXES = [
@@ -296,8 +298,9 @@ export type TelegramAttachmentResult = {
296
298
  };
297
299
 
298
300
  /**
299
- * Send attachments to a Telegram chat, using sendPhoto for images and
300
- * sendDocument for everything else.
301
+ * Send attachments to a Telegram chat, using sendPhoto for an image within
302
+ * the photo limit and sendDocument for everything else, so an image over it
303
+ * still arrives as a file.
301
304
  */
302
305
  export async function sendTelegramAttachments(
303
306
  chatId: string,
@@ -351,10 +354,10 @@ export async function sendTelegramAttachments(
351
354
  form.set("message_thread_id", String(threadFields.message_thread_id));
352
355
  }
353
356
 
354
- const isImage = TELEGRAM_IMAGE_MIME_PREFIXES.some((p) =>
355
- mimeType.startsWith(p),
356
- );
357
- if (isImage) {
357
+ const sendsAsPhoto =
358
+ TELEGRAM_IMAGE_MIME_PREFIXES.some((p) => mimeType.startsWith(p)) &&
359
+ content.length <= TELEGRAM_MAX_PHOTO_BYTES;
360
+ if (sendsAsPhoto) {
358
361
  form.set("photo", blob, filename);
359
362
  await callTelegramBotApiMultipart("sendPhoto", form);
360
363
  } else {
@@ -300,7 +300,9 @@ Local SSE via the assistant's broadcast mechanism. The `VellumAdapter` emits a `
300
300
  - `title` and `body` -- rendered notification copy
301
301
  - `deepLinkMetadata` -- optional metadata for navigating to the relevant context (e.g. `{ conversationId }`)
302
302
 
303
- The macOS client posts a native `UNUserNotificationCenter` notification from this payload. When the user taps the notification, the client uses `deepLinkMetadata` to navigate to the relevant conversation.
303
+ Every first-party client runs the shared web renderer (browser, desktop app, mobile apps), which posts a local notification from this payload and acks the delivery. When the user taps the notification, the client uses `deepLinkMetadata` to navigate to the relevant conversation.
304
+
305
+ A guardian-sensitive notification (approval requests, access requests, channel activation codes) carries `targetGuardianPrincipalId` and is published with `targetActorPrincipalId`, so the event hub delivers it only to connections authenticated as the guardian. The client shows it when the sending assistant is new enough to apply that targeting (`clients/web/src/lib/backwards-compat/guardian-notification-targeting.ts`) and drops it otherwise.
304
306
 
305
307
  ### Platform (always connected)
306
308
 
@@ -140,7 +140,7 @@ function makeSignal(
140
140
  createdAt: 1700000000000,
141
141
  sourceChannel: "scheduler",
142
142
  sourceContextId: "ctx-1",
143
- sourceEventName: "user.send_notification",
143
+ sourceEventName: "assistant.share",
144
144
  contextPayload: {},
145
145
  attentionHints: {
146
146
  requiresAction: false,
@@ -21,7 +21,7 @@ function makeSignal(
21
21
  createdAt: Date.now(),
22
22
  sourceChannel: "scheduler",
23
23
  sourceContextId: "ctx-1",
24
- sourceEventName: "user.send_notification",
24
+ sourceEventName: "assistant.share",
25
25
  contextPayload: {},
26
26
  attentionHints: {
27
27
  requiresAction: false,
@@ -99,7 +99,7 @@ function makeAssistantToolSignal(
99
99
  createdAt: Date.now(),
100
100
  sourceChannel: "assistant_tool",
101
101
  sourceContextId: "tool-call-1",
102
- sourceEventName: "user.send_notification",
102
+ sourceEventName: "assistant.share",
103
103
  contextPayload: {
104
104
  requestedMessage: "exact verbatim text here",
105
105
  requestedTitle: "Custom Title",
@@ -905,7 +905,9 @@ describe("scheduler requested-message pass-through in notification decision engi
905
905
  expect(decision.reasoningSummary).not.toBe(
906
906
  "scheduler requested-message pass-through",
907
907
  );
908
- expect(decision.reasoningSummary).not.toBe("schedule_result pass-through");
908
+ expect(decision.reasoningSummary).not.toBe(
909
+ "schedule_result pass-through",
910
+ );
909
911
  } finally {
910
912
  providerSendMessage = previousSendMessage;
911
913
  }
@@ -107,10 +107,10 @@ describe("checkRenderedCopyQuality (via runDeterministicChecks)", () => {
107
107
  });
108
108
 
109
109
  test("fails when body is the raw source event name", async () => {
110
- const signal = makeSignal({ sourceEventName: "user.send_notification" });
110
+ const signal = makeSignal({ sourceEventName: "assistant.share" });
111
111
  const decision = makeDecision({
112
112
  renderedCopy: {
113
- vellum: { title: "Reminder", body: "user.send_notification" },
113
+ vellum: { title: "Reminder", body: "assistant.share" },
114
114
  },
115
115
  });
116
116
  const result = await runDeterministicChecks(signal, decision, context);
@@ -119,10 +119,10 @@ describe("checkRenderedCopyQuality (via runDeterministicChecks)", () => {
119
119
  });
120
120
 
121
121
  test("fails when body matches the normalized source event name", async () => {
122
- const signal = makeSignal({ sourceEventName: "user.send_notification" });
122
+ const signal = makeSignal({ sourceEventName: "assistant.share" });
123
123
  const decision = makeDecision({
124
124
  renderedCopy: {
125
- vellum: { title: "Reminder", body: "user send notification" },
125
+ vellum: { title: "Reminder", body: "assistant share" },
126
126
  },
127
127
  });
128
128
  const result = await runDeterministicChecks(signal, decision, context);
@@ -174,11 +174,11 @@ describe("checkRenderedCopyQuality (via runDeterministicChecks)", () => {
174
174
  test("still validates body quality for channels with rendered copy", async () => {
175
175
  // Even when some channels lack copy (broadcaster fallback territory),
176
176
  // channels that DO have copy must still pass the empty/event-name checks.
177
- const signal = makeSignal({ sourceEventName: "user.send_notification" });
177
+ const signal = makeSignal({ sourceEventName: "assistant.share" });
178
178
  const decision = makeDecision({
179
179
  selectedChannels: ["vellum", "telegram"],
180
180
  renderedCopy: {
181
- telegram: { title: "Reminder", body: "user.send_notification" },
181
+ telegram: { title: "Reminder", body: "assistant.share" },
182
182
  },
183
183
  });
184
184
  const result = await runDeterministicChecks(signal, decision, {
@@ -194,7 +194,7 @@ describe("checkRenderedCopyQuality (via runDeterministicChecks)", () => {
194
194
  // a usable body (no template for sourceEventName → buildGenericCopy
195
195
  // returns body=""), the gate must fail-closed rather than letting
196
196
  // dispatchDecision report 0/N sent.
197
- const signal = makeSignal({ sourceEventName: "user.send_notification" });
197
+ const signal = makeSignal({ sourceEventName: "assistant.share" });
198
198
  const decision = makeDecision({
199
199
  selectedChannels: ["vellum"],
200
200
  renderedCopy: {},
@@ -218,7 +218,7 @@ describe("checkRenderedCopyQuality (via runDeterministicChecks)", () => {
218
218
  });
219
219
 
220
220
  test("passes when shouldNotify is false regardless of copy contents", async () => {
221
- const signal = makeSignal({ sourceEventName: "user.send_notification" });
221
+ const signal = makeSignal({ sourceEventName: "assistant.share" });
222
222
  const decision = makeDecision({
223
223
  shouldNotify: false,
224
224
  // Empty body + event-name body would both fail the copy check if
@@ -286,11 +286,11 @@ describe("checkRenderedCopyQuality (via runDeterministicChecks)", () => {
286
286
  test("still fails non-pass-through decision when body matches event name", async () => {
287
287
  // Regression guard: the pass-through short-circuit must not weaken
288
288
  // the check for LLM/fallback paths.
289
- const signal = makeSignal({ sourceEventName: "user.send_notification" });
289
+ const signal = makeSignal({ sourceEventName: "assistant.share" });
290
290
  const decision = makeDecision({
291
291
  reasoningSummary: "llm classification",
292
292
  renderedCopy: {
293
- vellum: { title: "Reminder", body: "user.send_notification" },
293
+ vellum: { title: "Reminder", body: "assistant.share" },
294
294
  },
295
295
  });
296
296
  const result = await runDeterministicChecks(signal, decision, context);
@@ -281,7 +281,9 @@ describe("writeHomeFeedItemForSignal", () => {
281
281
  "The deployment completed successfully.",
282
282
  );
283
283
  expect(appendCalls[0]!.conversationId).toBe("conv-source-1");
284
- expect(appendCalls[0]!.metadata?.notificationConversationMessageId).toBeUndefined();
284
+ expect(
285
+ appendCalls[0]!.metadata?.notificationConversationMessageId,
286
+ ).toBeUndefined();
285
287
  expect(messageAppends).toEqual([]);
286
288
  expect(messagesInvalidated).toEqual([]);
287
289
  expect(conversationLookups).toEqual(["conv-source-1", "conv-source-1"]);
@@ -956,7 +958,7 @@ describe("writeHomeFeedItemForSignal", () => {
956
958
  conversationRow = { conversationType: "background" };
957
959
  const signal = makeSignal({
958
960
  sourceChannel: "assistant_tool",
959
- sourceEventName: "user.send_notification",
961
+ sourceEventName: "assistant.share",
960
962
  contextPayload: { title: "Tool share", body: "Body" },
961
963
  });
962
964
 
@@ -970,7 +972,7 @@ describe("writeHomeFeedItemForSignal", () => {
970
972
  conversationRow = { conversationType: "background" };
971
973
  const signal = makeSignal({
972
974
  sourceChannel: "assistant_tool",
973
- sourceEventName: "user.send_notification",
975
+ sourceEventName: "assistant.share",
974
976
  contextPayload: { title: "Tool share", body: "Body" },
975
977
  });
976
978
 
@@ -1126,7 +1128,7 @@ describe("writeHomeFeedItemForSignal", () => {
1126
1128
  conversationRow = { conversationType: "background" };
1127
1129
  const signal = makeSignal({
1128
1130
  sourceChannel: "assistant_tool",
1129
- sourceEventName: "user.send_notification",
1131
+ sourceEventName: "assistant.share",
1130
1132
  contextPayload: { title: "Tool share", body: "Body" },
1131
1133
  });
1132
1134
 
@@ -331,19 +331,6 @@ const TEMPLATES: Partial<Record<NotificationSourceEventName, CopyTemplate>> = {
331
331
  body: str(payload.body, "A watcher event occurred"),
332
332
  }),
333
333
 
334
- "watcher.escalation": (payload) => ({
335
- title: str(payload.title, "Watcher Escalation"),
336
- body: str(payload.body, "A watcher event requires your attention"),
337
- }),
338
-
339
- "tool_confirmation.required_action": (payload) => {
340
- const toolName = str(payload.toolName, "A tool");
341
- return {
342
- title: `${toolName} needs your confirmation`,
343
- body: `${toolName} requires your confirmation`,
344
- };
345
- },
346
-
347
334
  // Titled by what was done rather than by the kind of event: the summary's
348
335
  // first sentence is the outcome ("Finished the fuel-system diagnostic app"),
349
336
  // which is what a reader scanning the bell wants to see. A producer that
@@ -385,16 +372,6 @@ const TEMPLATES: Partial<Record<NotificationSourceEventName, CopyTemplate>> = {
385
372
  body: summary !== "" ? summary : describeUnclassifiedFailure(payload),
386
373
  };
387
374
  },
388
-
389
- "quick_chat.response_ready": (payload) => ({
390
- title: "Quick chat reply ready",
391
- body: str(payload.preview, "Your quick chat response is ready"),
392
- }),
393
-
394
- "voice.response_ready": (payload) => ({
395
- title: "Voice reply ready",
396
- body: str(payload.preview, "A voice response is ready"),
397
- }),
398
375
  };
399
376
 
400
377
  /**
@@ -254,7 +254,7 @@ function checkDedupe(
254
254
  /**
255
255
  * Fail-closed check that the rendered copy is real text and not an
256
256
  * accidental fallback leak (empty body, or body that is just the raw
257
- * source event name like "user.send_notification").
257
+ * source event name like "assistant.share").
258
258
  *
259
259
  * Only validates channels that the decision engine actually emitted
260
260
  * copy for. Channels appended after the decision (urgency-forced
@@ -459,12 +459,10 @@ const EVENT_CATEGORY_MAP: Record<string, FeedItemCategory> = {
459
459
 
460
460
  /**
461
461
  * Map a signal's source event to a feed category, or nothing when the event
462
- * has no entry. An unmapped event used to land in `system`, a bucket named
463
- * for our architecture rather than the user's world, and every deliberate
464
- * assistant notification (`user.send_notification`) ended up there. The
465
- * category is optional on the wire, so an event without a home simply
466
- * carries none: readers that filter by category skip it, and nothing has
467
- * to guess.
462
+ * has no entry. An unmapped event, such as a deliberate assistant
463
+ * notification (`assistant.share`), carries no category rather than a
464
+ * catch-all bucket named for our architecture: readers that filter by
465
+ * category skip it, and nothing has to guess.
468
466
  */
469
467
  function deriveCategory(
470
468
  signal: NotificationSignal,
@@ -59,8 +59,9 @@ export function isNotificationSourceChannel(
59
59
 
60
60
  export const NOTIFICATION_SOURCE_EVENT_NAMES = [
61
61
  {
62
- id: "user.send_notification",
63
- description: "User-initiated notification via assistant tool",
62
+ id: "assistant.share",
63
+ description:
64
+ "Assistant chose to tell the user something (`assistant notifications send`)",
64
65
  },
65
66
  {
66
67
  id: "schedule.notify",
@@ -113,28 +114,12 @@ export const NOTIFICATION_SOURCE_EVENT_NAMES = [
113
114
  id: "watcher.notification",
114
115
  description: "Watcher detected a notable event",
115
116
  },
116
- {
117
- id: "watcher.escalation",
118
- description: "Watcher event requiring immediate attention",
119
- },
120
- {
121
- id: "tool_confirmation.required_action",
122
- description: "Tool requires user confirmation before executing",
123
- },
124
117
  { id: "activity.complete", description: "Background activity finished" },
125
118
  {
126
119
  id: "activity.failed",
127
120
  description:
128
121
  "Background job execution failed (model_provider, exception, or timeout)",
129
122
  },
130
- {
131
- id: "quick_chat.response_ready",
132
- description: "Quick chat response ready for review",
133
- },
134
- {
135
- id: "voice.response_ready",
136
- description: "Voice response ready for playback",
137
- },
138
123
  {
139
124
  id: "credential.health_alert",
140
125
  description:
@@ -277,6 +277,8 @@ Verification SESSION state (sessions, secrets, rate limits, validate+consume) AN
277
277
 
278
278
  The daemon relays session lifecycle operations over the `verification_sessions_*` IPC routes via `assistant/src/channels/gateway-verification-sessions.ts` and keeps what is presentation: message composition and channel delivery (`channel-verification-routes.ts`, `verification-outbound-actions.ts`). `channel-verification-service.ts` retains only guardian-delivery reads (`getGuardianBinding`, `isGuardian`, `isGuardianBoundForChannel`).
279
279
 
280
+ The gateway answers a code (or an invite) it consumed at ingress with a reply it composes itself, and delivers it through the daemon's IPC-only `deliver_gateway_reply` method (`ipc/routes/channel-reply-ipc-routes.ts`), which hands it to the channel transport the inbound message's callback URL names. The gateway never sends to a provider itself: the transports are the one outbound path per channel, whoever composed the text. The reply is not recorded in a conversation, since the message it answers never entered one.
281
+
280
282
  The verified outcome is written in-process by the gateway: the HTTP guardian-attest handler calls `ContactStore.markChannelVerified` directly (verifiedVia "manual"); the code-match paths (text and the `verification_sessions_validate_consume` engine route) apply role side effects in-engine — guardian phone binding commits in the same gateway transaction as the consume. The revoke/downgrade outcome is relayed from the daemon via `ipcCallPersistent("mark_channel_revoked", …)` to `ContactStore.markChannelRevoked`.
281
283
 
282
284
  ## Rate Limiting & Diagnostics