// push: notifications to the owner's devices import { z } from "zod"; // The daemon is the only tier that knows what the agent is doing, so it is the sender. A registration is per-device, in // two kinds: // webpush: a browser (including the Android TWA); the daemon sends to its push service directly, end-to-end encrypted. // relay: a native app (the iPhone app, the Android editor app, the Intentic Device app); its OS push service only accepts // sends from the vendor, so the daemon posts through a relay, which can read the payload. Only Apple is handed the words: // an Android app gets a data-only message and fetches them from the daemon itself. // Exact shape `web-push` consumes, from `PushManager.subscribe()`; posted back verbatim. export const WebPushChannelSchema = z.object({ kind: z.literal("webpush").describe("A browser, which the sandbox can reach directly and encrypt end to end."), endpoint: z.url().describe("Where that browser's push service accepts sends. It also identifies the device everywhere else in this group."), keys: z .object({ p256dh: z.string().min(1).describe("The browser's public key, for encrypting what is sent."), auth: z.string().min(1).describe("The browser's secret, for the same."), }) .describe("What the browser handed you when it subscribed. Post it back exactly as it came; nothing reshapes it."), }); export type WebPushChannel = z.infer; export const RelayChannelSchema = z.object({ kind: z .literal("relay") .describe( "A native app, whose operating system only accepts sends from the app's publisher, so the sandbox posts through a relay instead. The message passes through that relay readable, which is the price of the publisher having to be in the loop.", ), url: z.url().describe("Where to post a send. Recorded rather than assumed, so the sandbox need not know any platform by name."), deviceId: z.string().min(1).describe("The device's id, which also identifies this registration everywhere else in this group."), secret: z .string() .min(1) .describe( "Proof that this sandbox may notify this device. A send names no sandbox; the relay knows which sandbox a device belongs to only when the editor app registered it for that one sandbox, so the app can be told which sandbox to ask.", ), }); export type RelayChannel = z.infer; export const PushChannelSchema = z.discriminatedUnion("kind", [WebPushChannelSchema, RelayChannelSchema]); export type PushChannel = z.infer; // The one identity every push route uses (subscribe, unsubscribe, config); derived from the channel's own shape so the // daemon and web app can't disagree about it. export const channelId = (channel: PushChannel): string => (channel.kind === "webpush" ? channel.endpoint : channel.deviceId); export const PushNotificationSchema = z.object({ title: z.string().min(1).describe("The headline."), body: z .string() .describe( "The line under it. Push services cap the whole payload at a few kilobytes, which is why nothing here carries a transcript or a diff: a notification is a pointer back, not a delivery.", ), url: z.string().optional().describe("Where tapping it goes. An existing tab is focused rather than a new one opened."), tag: z .string() .optional() .describe("Collapses repeats: a second notification with the same tag replaces the first instead of stacking beside it."), requireInteraction: z .boolean() .optional() .describe( "Keep it on screen until it is dismissed. Used when the agent is waiting for you, where one that fades away is a question that went unanswered in silence.", ), // Optional so an older service worker or relay, which ignores it, still shows the replacement under the same tag. silent: z .boolean() .optional() .describe( "Arrive without sound or vibration. Used for the replacement that says an ask stopped waiting: it takes the waiting one's place under the same tag, and is news nobody has to act on.", ), }); export type PushNotification = z.infer; export const PushSentQuerySchema = z.object({ tag: z.string().min(1).describe("The tag the push carried, which is all a native app's push says about what it was."), }); // What a native app's data-only push leaves out, read back over the app's own session so the words never pass through // the push service or the relay. export const PushSentNotificationSchema = PushNotificationSchema.extend({ tag: z.string().min(1).describe("The tag it was sent under, the one asked for."), sentAt: z.string().describe("When the sandbox sent it (ISO)."), }); export type PushSentNotification = z.infer; export const PushConfigSchema = z.object({ publicKey: z.string().describe("The key a browser needs in order to subscribe. Native apps ignore it."), subscribed: z .boolean() .describe( "Whether the asking device is already registered, so a toggle can show its real state instead of trusting the device's own permission, which can be granted with nothing behind it.", ), }); export type PushConfig = z.infer; export const PushChannelIdSchema = z.object({ id: z.string().min(1).describe("Which device: a browser's push address, or a native install's device id."), }); export const PushConfigQuerySchema = z.object({ id: z .string() .min(1) .optional() .describe("Which device is asking. Without it the answer can only speak for the sandbox as a whole, which is rarely the question."), }); export const PushTestSchema = z.object({ delivered: z .number() .int() .nonnegative() .describe( "How many devices actually accepted it. A count rather than a yes, because this button exists to prove a chain nobody can inspect, and the sandbox having accepted the request is not the question being asked.", ), }); export type PushTest = z.infer;