diff --git a/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx b/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx
index 1626ec9f91..39a3d38678 100644
--- a/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx
+++ b/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx
@@ -11,6 +11,7 @@ import {
import { cn } from "~/utils/cn";
import { DiscordIcon, SlackIcon } from "@trigger.dev/companyicons";
import { Fragment, useState } from "react";
+import { useRecentChangelogs } from "~/routes/resources.platform-changelogs";
import { motion } from "framer-motion";
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
@@ -38,6 +39,7 @@ export function HelpAndFeedback({
}) {
const [isHelpMenuOpen, setHelpMenuOpen] = useState(false);
const currentPlan = useCurrentPlan();
+ const { changelogs } = useRecentChangelogs();
useShortcutKeys({
shortcut: disableShortcut ? undefined : { key: "h", enabledOnInputElements: false },
@@ -140,96 +142,7 @@ export function HelpAndFeedback({
data-action="suggest-a-feature"
target="_blank"
/>
-
-
-
-
Need help?
- {currentPlan?.v3Subscription?.plan?.limits.support === "slack" && (
-
-
-
- )}
-
-
+
+
What's new
+ {changelogs.map((entry) => (
+
+ ))}
+
+
);
}
+
+function GrayDotIcon({ className }: { className?: string }) {
+ return (
+
+
+
+ );
+}
diff --git a/apps/webapp/app/components/navigation/NotificationPanel.tsx b/apps/webapp/app/components/navigation/NotificationPanel.tsx
new file mode 100644
index 0000000000..fcce21a9ab
--- /dev/null
+++ b/apps/webapp/app/components/navigation/NotificationPanel.tsx
@@ -0,0 +1,274 @@
+import { BellAlertIcon, ChevronRightIcon, XMarkIcon } from "@heroicons/react/20/solid";
+import { useFetcher } from "@remix-run/react";
+import { motion } from "framer-motion";
+import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
+import ReactMarkdown from "react-markdown";
+import { Header3 } from "~/components/primitives/Headers";
+import { Popover, PopoverContent, PopoverTrigger } from "~/components/primitives/Popover";
+import { SimpleTooltip } from "~/components/primitives/Tooltip";
+import { usePlatformNotifications } from "~/routes/resources.platform-notifications";
+import { cn } from "~/utils/cn";
+
+type Notification = {
+ id: string;
+ friendlyId: string;
+ scope: string;
+ priority: number;
+ payload: {
+ version: string;
+ data: {
+ title: string;
+ description: string;
+ image?: string;
+ actionLabel?: string;
+ actionUrl?: string;
+ dismissOnAction?: boolean;
+ };
+ };
+ isRead: boolean;
+};
+
+export function NotificationPanel({
+ isCollapsed,
+ hasIncident,
+ organizationId,
+ projectId,
+}: {
+ isCollapsed: boolean;
+ hasIncident: boolean;
+ organizationId: string;
+ projectId: string;
+}) {
+ const { notifications } = usePlatformNotifications(organizationId, projectId) as {
+ notifications: Notification[];
+ };
+ const [dismissedIds, setDismissedIds] = useState>(new Set());
+ const dismissFetcher = useFetcher();
+ const seenIdsRef = useRef>(new Set());
+ const seenFetcher = useFetcher();
+
+ const visibleNotifications = notifications.filter((n) => !dismissedIds.has(n.id));
+ const notification = visibleNotifications[0] ?? null;
+
+ const handleDismiss = useCallback((id: string) => {
+ setDismissedIds((prev) => new Set(prev).add(id));
+
+ dismissFetcher.submit(
+ {},
+ {
+ method: "POST",
+ action: `/resources/platform-notifications/${id}/dismiss`,
+ }
+ );
+ }, []);
+
+ // Fire seen beacon
+ const fireSeenBeacon = useCallback((n: Notification) => {
+ if (seenIdsRef.current.has(n.id)) return;
+ seenIdsRef.current.add(n.id);
+
+ seenFetcher.submit(
+ {},
+ {
+ method: "POST",
+ action: `/resources/platform-notifications/${n.id}/seen`,
+ }
+ );
+ }, []);
+
+ // Beacon current notification on mount
+ useEffect(() => {
+ if (notification && !hasIncident) {
+ fireSeenBeacon(notification);
+ }
+ }, [notification?.id, hasIncident]);
+
+ if (!notification) {
+ return null;
+ }
+
+ const card = (
+
+ );
+
+ return (
+
+
+ {/* Expanded sidebar: show card directly */}
+
+ {card}
+
+
+ {/* Collapsed sidebar: show bell icon that opens popover */}
+
+
+
+
+
+ {visibleNotifications.length}
+
+
+
+ }
+ content="Notifications"
+ side="right"
+ sideOffset={8}
+ disableHoverableContent
+ asChild
+ />
+
+
+
+ {card}
+
+
+ );
+}
+
+function NotificationCard({
+ notification,
+ onDismiss,
+}: {
+ notification: Notification;
+ onDismiss: (id: string) => void;
+}) {
+ const { title, description, image, actionUrl, dismissOnAction } = notification.payload.data;
+ const [isExpanded, setIsExpanded] = useState(false);
+ const [isOverflowing, setIsOverflowing] = useState(false);
+ const descriptionRef = useRef(null);
+
+ useLayoutEffect(() => {
+ const el = descriptionRef.current;
+ if (el) {
+ setIsOverflowing(el.scrollHeight > el.clientHeight);
+ }
+ }, [description]);
+
+ const handleDismiss = (e: React.MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ onDismiss(notification.id);
+ };
+
+ const handleToggleExpand = (e: React.MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setIsExpanded((v) => !v);
+ };
+
+ const handleCardClick = () => {
+ if (dismissOnAction) {
+ onDismiss(notification.id);
+ }
+ };
+
+ const Wrapper = actionUrl ? "a" : "div";
+ const wrapperProps = actionUrl
+ ? {
+ href: actionUrl,
+ target: "_blank" as const,
+ rel: "noopener noreferrer" as const,
+ onClick: handleCardClick,
+ }
+ : {};
+
+ return (
+
+ {/* Header: title + dismiss */}
+
+
+ {title}
+
+
+
+
+ {/* Body: description + chevron */}
+
+
+
+
+ {description}
+
+ {(isOverflowing || isExpanded) && (
+
+ )}
+
+ {actionUrl && (
+
+
+
+ )}
+
+
+ {image && (
+

+ )}
+
+
+ );
+}
+
+const markdownComponents = {
+ p: ({ children }: { children?: React.ReactNode }) => (
+ {children}
+ ),
+ a: ({ href, children }: { href?: string; children?: React.ReactNode }) => (
+
+ {children}
+
+ ),
+ strong: ({ children }: { children?: React.ReactNode }) => (
+ {children}
+ ),
+ em: ({ children }: { children?: React.ReactNode }) => {children},
+ code: ({ children }: { children?: React.ReactNode }) => (
+ {children}
+ ),
+};
diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx
index 89bf139c98..97c280a201 100644
--- a/apps/webapp/app/components/navigation/SideMenu.tsx
+++ b/apps/webapp/app/components/navigation/SideMenu.tsx
@@ -50,6 +50,7 @@ import { type UserWithDashboardPreferences } from "~/models/user.server";
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
import { type FeedbackType } from "~/routes/resources.feedback";
import { IncidentStatusPanel, useIncidentStatus } from "~/routes/resources.incidents";
+import { NotificationPanel } from "./NotificationPanel";
import { cn } from "~/utils/cn";
import {
accountPath,
@@ -652,6 +653,12 @@ export function SideMenu({
hasIncident={incidentStatus.hasIncident}
isManagedCloud={incidentStatus.isManagedCloud}
/>
+
> {
+ const authResult = await authenticateApiRequestWithPersonalAccessToken(request);
+ if (!authResult) {
+ return err({ status: 401, message: "Invalid or Missing API key" });
+ }
+
+ const user = await prisma.user.findUnique({
+ where: { id: authResult.userId },
+ select: { id: true, admin: true },
+ });
+
+ if (!user) {
+ return err({ status: 401, message: "Invalid or Missing API key" });
+ }
+
+ if (!user.admin) {
+ return err({ status: 403, message: "You must be an admin to perform this action" });
+ }
+
+ return ok(user);
+}
+
+export async function action({ request }: ActionFunctionArgs) {
+ if (request.method !== "POST") {
+ return json({ error: "Method not allowed" }, { status: 405 });
+ }
+
+ const authResult = await authenticateAdmin(request);
+ if (authResult.isErr()) {
+ const { status, message } = authResult.error;
+ return json({ error: message }, { status });
+ }
+
+ const body = await request.json();
+ const result = await createPlatformNotification(body as CreatePlatformNotificationInput);
+
+ if (result.isErr()) {
+ const error = result.error;
+
+ if (error.type === "validation") {
+ return json({ error: "Validation failed", details: error.issues }, { status: 400 });
+ }
+
+ return json({ error: error.message }, { status: 500 });
+ }
+
+ return json(result.value, { status: 201 });
+}
diff --git a/apps/webapp/app/routes/api.v1.platform-notifications.ts b/apps/webapp/app/routes/api.v1.platform-notifications.ts
new file mode 100644
index 0000000000..7299c40738
--- /dev/null
+++ b/apps/webapp/app/routes/api.v1.platform-notifications.ts
@@ -0,0 +1,22 @@
+import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
+import { json } from "@remix-run/server-runtime";
+import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
+import { getNextCliNotification } from "~/services/platformNotifications.server";
+
+export async function loader({ request }: LoaderFunctionArgs) {
+ const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
+
+ if (!authenticationResult) {
+ return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
+ }
+
+ const url = new URL(request.url);
+ const projectRef = url.searchParams.get("projectRef") ?? undefined;
+
+ const notification = await getNextCliNotification({
+ userId: authenticationResult.userId,
+ projectRef,
+ });
+
+ return json({ notification });
+}
diff --git a/apps/webapp/app/routes/resources.platform-changelogs.tsx b/apps/webapp/app/routes/resources.platform-changelogs.tsx
new file mode 100644
index 0000000000..da71df2e27
--- /dev/null
+++ b/apps/webapp/app/routes/resources.platform-changelogs.tsx
@@ -0,0 +1,49 @@
+import { json } from "@remix-run/node";
+import type { LoaderFunctionArgs } from "@remix-run/node";
+import { useFetcher, type ShouldRevalidateFunction } from "@remix-run/react";
+import { useEffect, useRef } from "react";
+import { requireUserId } from "~/services/session.server";
+import { getRecentChangelogs } from "~/services/platformNotifications.server";
+
+export const shouldRevalidate: ShouldRevalidateFunction = () => false;
+
+export type PlatformChangelogsLoaderData = {
+ changelogs: Array<{ id: string; title: string; actionUrl?: string }>;
+};
+
+export async function loader({ request }: LoaderFunctionArgs) {
+ await requireUserId(request);
+
+ const changelogs = await getRecentChangelogs();
+
+ return json({ changelogs });
+}
+
+const POLL_INTERVAL_MS = 60_000;
+
+export function useRecentChangelogs() {
+ const fetcher = useFetcher();
+ const hasInitiallyFetched = useRef(false);
+
+ useEffect(() => {
+ const url = "/resources/platform-changelogs";
+
+ if (!hasInitiallyFetched.current && fetcher.state === "idle") {
+ hasInitiallyFetched.current = true;
+ fetcher.load(url);
+ }
+
+ const interval = setInterval(() => {
+ if (fetcher.state === "idle") {
+ fetcher.load(url);
+ }
+ }, POLL_INTERVAL_MS);
+
+ return () => clearInterval(interval);
+ }, []);
+
+ return {
+ changelogs: fetcher.data?.changelogs ?? [],
+ isLoading: fetcher.state !== "idle",
+ };
+}
diff --git a/apps/webapp/app/routes/resources.platform-notifications.$id.dismiss.tsx b/apps/webapp/app/routes/resources.platform-notifications.$id.dismiss.tsx
new file mode 100644
index 0000000000..e6a475692d
--- /dev/null
+++ b/apps/webapp/app/routes/resources.platform-notifications.$id.dismiss.tsx
@@ -0,0 +1,17 @@
+import { json } from "@remix-run/node";
+import type { ActionFunctionArgs } from "@remix-run/node";
+import { requireUserId } from "~/services/session.server";
+import { dismissNotification } from "~/services/platformNotifications.server";
+
+export async function action({ request, params }: ActionFunctionArgs) {
+ const userId = await requireUserId(request);
+ const notificationId = params.id;
+
+ if (!notificationId) {
+ return json({ success: false }, { status: 400 });
+ }
+
+ await dismissNotification({ notificationId, userId });
+
+ return json({ success: true });
+}
diff --git a/apps/webapp/app/routes/resources.platform-notifications.$id.seen.tsx b/apps/webapp/app/routes/resources.platform-notifications.$id.seen.tsx
new file mode 100644
index 0000000000..652d4ee99c
--- /dev/null
+++ b/apps/webapp/app/routes/resources.platform-notifications.$id.seen.tsx
@@ -0,0 +1,17 @@
+import { json } from "@remix-run/node";
+import type { ActionFunctionArgs } from "@remix-run/node";
+import { requireUserId } from "~/services/session.server";
+import { recordNotificationSeen } from "~/services/platformNotifications.server";
+
+export async function action({ request, params }: ActionFunctionArgs) {
+ const userId = await requireUserId(request);
+ const notificationId = params.id;
+
+ if (!notificationId) {
+ return json({ success: false }, { status: 400 });
+ }
+
+ await recordNotificationSeen({ notificationId, userId });
+
+ return json({ success: true });
+}
diff --git a/apps/webapp/app/routes/resources.platform-notifications.tsx b/apps/webapp/app/routes/resources.platform-notifications.tsx
new file mode 100644
index 0000000000..0a97a26d08
--- /dev/null
+++ b/apps/webapp/app/routes/resources.platform-notifications.tsx
@@ -0,0 +1,61 @@
+import { json } from "@remix-run/node";
+import type { LoaderFunctionArgs } from "@remix-run/node";
+import { useFetcher, type ShouldRevalidateFunction } from "@remix-run/react";
+import { useEffect, useRef } from "react";
+import { requireUserId } from "~/services/session.server";
+import {
+ getActivePlatformNotifications,
+ type PlatformNotificationWithPayload,
+} from "~/services/platformNotifications.server";
+
+export const shouldRevalidate: ShouldRevalidateFunction = () => false;
+
+export type PlatformNotificationsLoaderData = {
+ notifications: PlatformNotificationWithPayload[];
+ unreadCount: number;
+};
+
+export async function loader({ request }: LoaderFunctionArgs) {
+ const userId = await requireUserId(request);
+ const url = new URL(request.url);
+ const organizationId = url.searchParams.get("organizationId");
+ const projectId = url.searchParams.get("projectId") ?? undefined;
+
+ if (!organizationId) {
+ return json({ notifications: [], unreadCount: 0 });
+ }
+
+ const result = await getActivePlatformNotifications({ userId, organizationId, projectId });
+
+ return json(result);
+}
+
+const POLL_INTERVAL_MS = 60000; // 1 minute
+
+export function usePlatformNotifications(organizationId: string, projectId: string) {
+ const fetcher = useFetcher();
+ const hasInitiallyFetched = useRef(false);
+
+ useEffect(() => {
+ const url = `/resources/platform-notifications?organizationId=${encodeURIComponent(organizationId)}&projectId=${encodeURIComponent(projectId)}`;
+
+ if (!hasInitiallyFetched.current && fetcher.state === "idle") {
+ hasInitiallyFetched.current = true;
+ fetcher.load(url);
+ }
+
+ const interval = setInterval(() => {
+ if (fetcher.state === "idle") {
+ fetcher.load(url);
+ }
+ }, POLL_INTERVAL_MS);
+
+ return () => clearInterval(interval);
+ }, [organizationId, projectId]);
+
+ return {
+ notifications: fetcher.data?.notifications ?? [],
+ unreadCount: fetcher.data?.unreadCount ?? 0,
+ isLoading: fetcher.state !== "idle",
+ };
+}
diff --git a/apps/webapp/app/services/platformNotifications.server.ts b/apps/webapp/app/services/platformNotifications.server.ts
new file mode 100644
index 0000000000..3a732664bd
--- /dev/null
+++ b/apps/webapp/app/services/platformNotifications.server.ts
@@ -0,0 +1,513 @@
+import { z } from "zod";
+import { errAsync, fromPromise, type ResultAsync } from "neverthrow";
+import { prisma } from "~/db.server";
+import { type PlatformNotificationScope, type PlatformNotificationSurface } from "@trigger.dev/database";
+
+// --- Payload schema (spec v1) ---
+
+const DiscoverySchema = z.object({
+ filePatterns: z.array(z.string().min(1)).min(1),
+ contentPattern: z
+ .string()
+ .optional()
+ .refine(
+ (val) => {
+ if (!val) return true;
+ try {
+ new RegExp(val);
+ return true;
+ } catch {
+ return false;
+ }
+ },
+ { message: "contentPattern must be a valid regular expression" }
+ ),
+ matchBehavior: z.enum(["show-if-found", "show-if-not-found"]),
+});
+
+const CardDataV1Schema = z.object({
+ type: z.enum(["card", "info", "warn", "error", "success", "changelog"]),
+ title: z.string(),
+ description: z.string(),
+ image: z.string().url().optional(),
+ actionLabel: z.string().optional(),
+ actionUrl: z.string().url().optional(),
+ dismissOnAction: z.boolean().optional(),
+ discovery: DiscoverySchema.optional(),
+});
+
+const PayloadV1Schema = z.object({
+ version: z.literal("1"),
+ data: CardDataV1Schema,
+});
+
+export type PayloadV1 = z.infer;
+
+export type PlatformNotificationWithPayload = {
+ id: string;
+ friendlyId: string;
+ scope: string;
+ priority: number;
+ payload: PayloadV1;
+ isRead: boolean;
+};
+
+// --- Read: active notifications for webapp ---
+
+export async function getActivePlatformNotifications({
+ userId,
+ organizationId,
+ projectId,
+}: {
+ userId: string;
+ organizationId: string;
+ projectId?: string;
+}) {
+ const now = new Date();
+
+ const notifications = await prisma.platformNotification.findMany({
+ where: {
+ surface: "WEBAPP",
+ archivedAt: null,
+ startsAt: { lte: now },
+ OR: [{ endsAt: null }, { endsAt: { gt: now } }],
+ AND: [
+ {
+ OR: [
+ { scope: "GLOBAL" },
+ { scope: "ORGANIZATION", organizationId },
+ ...(projectId ? [{ scope: "PROJECT" as const, projectId }] : []),
+ { scope: "USER", userId },
+ ],
+ },
+ ],
+ },
+ include: {
+ interactions: {
+ where: { userId },
+ },
+ },
+ orderBy: [{ priority: "desc" }, { createdAt: "desc" }],
+ });
+
+ type InternalNotification = PlatformNotificationWithPayload & { createdAt: Date };
+ const result: InternalNotification[] = [];
+
+ for (const n of notifications) {
+ const interaction = n.interactions[0] ?? null;
+
+ if (interaction?.webappDismissedAt) continue;
+
+ const parsed = PayloadV1Schema.safeParse(n.payload);
+ if (!parsed.success) continue;
+
+ result.push({
+ id: n.id,
+ friendlyId: n.friendlyId,
+ scope: n.scope,
+ priority: n.priority,
+ createdAt: n.createdAt,
+ payload: parsed.data,
+ isRead: !!interaction,
+ });
+ }
+
+ result.sort(compareNotifications);
+
+ const unreadCount = result.filter((n) => !n.isRead).length;
+ const notifications_out: PlatformNotificationWithPayload[] = result.map(
+ ({ createdAt: _, ...rest }) => rest
+ );
+
+ return { notifications: notifications_out, unreadCount };
+}
+
+function compareNotifications(
+ a: { priority: number; createdAt: Date },
+ b: { priority: number; createdAt: Date }
+) {
+ const priorityDiff = b.priority - a.priority;
+ if (priorityDiff !== 0) return priorityDiff;
+
+ return b.createdAt.getTime() - a.createdAt.getTime();
+}
+
+// --- Write: upsert interaction ---
+
+async function upsertInteraction({
+ notificationId,
+ userId,
+ onUpdate,
+ onCreate,
+}: {
+ notificationId: string;
+ userId: string;
+ onUpdate: Record;
+ onCreate: Record;
+}) {
+ const existing = await prisma.platformNotificationInteraction.findUnique({
+ where: { notificationId_userId: { notificationId, userId } },
+ });
+
+ if (existing) {
+ await prisma.platformNotificationInteraction.update({
+ where: { id: existing.id },
+ data: onUpdate,
+ });
+ return;
+ }
+
+ await prisma.platformNotificationInteraction.create({
+ data: {
+ notificationId,
+ userId,
+ firstSeenAt: new Date(),
+ showCount: 1,
+ ...onCreate,
+ },
+ });
+}
+
+export async function recordNotificationSeen({
+ notificationId,
+ userId,
+}: {
+ notificationId: string;
+ userId: string;
+}) {
+ return upsertInteraction({
+ notificationId,
+ userId,
+ onUpdate: { showCount: { increment: 1 } },
+ onCreate: {},
+ });
+}
+
+export async function dismissNotification({
+ notificationId,
+ userId,
+}: {
+ notificationId: string;
+ userId: string;
+}) {
+ const now = new Date();
+ return upsertInteraction({
+ notificationId,
+ userId,
+ onUpdate: { webappDismissedAt: now },
+ onCreate: { webappDismissedAt: now },
+ });
+}
+
+// --- Read: recent changelogs (for Help & Feedback) ---
+
+export async function getRecentChangelogs({ limit = 2 }: { limit?: number } = {}) {
+ const now = new Date();
+
+ const notifications = await prisma.platformNotification.findMany({
+ where: {
+ surface: "WEBAPP",
+ archivedAt: null,
+ startsAt: { lte: now },
+ OR: [{ endsAt: null }, { endsAt: { gt: now } }],
+ payload: { path: ["data", "type"], equals: "changelog" },
+ },
+ orderBy: [{ createdAt: "desc" }],
+ take: limit,
+ });
+
+ return notifications
+ .map((n) => {
+ const parsed = PayloadV1Schema.safeParse(n.payload);
+ if (!parsed.success) return null;
+ return { id: n.id, title: parsed.data.data.title, actionUrl: parsed.data.data.actionUrl };
+ })
+ .filter(Boolean) as Array<{ id: string; title: string; actionUrl?: string }>;
+}
+
+// --- CLI: next notification for CLI surface ---
+
+function isCliNotificationExpired(
+ interaction: { firstSeenAt: Date; showCount: number } | null,
+ notification: { cliMaxDaysAfterFirstSeen: number | null; cliMaxShowCount: number | null }
+): boolean {
+ if (!interaction) return false;
+
+ if (
+ notification.cliMaxShowCount !== null &&
+ interaction.showCount >= notification.cliMaxShowCount
+ ) {
+ return true;
+ }
+
+ if (notification.cliMaxDaysAfterFirstSeen !== null) {
+ const daysSinceFirstSeen =
+ (Date.now() - interaction.firstSeenAt.getTime()) / (1000 * 60 * 60 * 24);
+ if (daysSinceFirstSeen > notification.cliMaxDaysAfterFirstSeen) {
+ return true;
+ }
+ }
+
+ return false;
+}
+
+export async function getNextCliNotification({
+ userId,
+ projectRef,
+}: {
+ userId: string;
+ projectRef?: string;
+}): Promise<{
+ id: string;
+ payload: PayloadV1;
+ showCount: number;
+ firstSeenAt: string;
+} | null> {
+ const now = new Date();
+
+ // Resolve organizationId and projectId from projectRef if provided
+ let organizationId: string | undefined;
+ let projectId: string | undefined;
+
+ if (projectRef) {
+ const project = await prisma.project.findFirst({
+ where: {
+ externalRef: projectRef,
+ deletedAt: null,
+ organization: {
+ deletedAt: null,
+ members: { some: { userId } },
+ },
+ },
+ select: { id: true, organizationId: true },
+ });
+
+ if (project) {
+ projectId = project.id;
+ organizationId = project.organizationId;
+ }
+ }
+
+ // If no projectRef or project not found, get org from membership
+ if (!organizationId) {
+ const membership = await prisma.orgMember.findFirst({
+ where: { userId },
+ select: { organizationId: true },
+ });
+ if (membership) {
+ organizationId = membership.organizationId;
+ }
+ }
+
+ const scopeFilter: Array> = [
+ { scope: "GLOBAL" },
+ { scope: "USER", userId },
+ ];
+
+ if (organizationId) {
+ scopeFilter.push({ scope: "ORGANIZATION", organizationId });
+ }
+
+ if (projectId) {
+ scopeFilter.push({ scope: "PROJECT", projectId });
+ }
+
+ const notifications = await prisma.platformNotification.findMany({
+ where: {
+ surface: "CLI",
+ archivedAt: null,
+ startsAt: { lte: now },
+ OR: [{ endsAt: null }, { endsAt: { gt: now } }],
+ AND: [{ OR: scopeFilter }],
+ },
+ include: {
+ interactions: {
+ where: { userId },
+ },
+ },
+ orderBy: [{ priority: "desc" }, { createdAt: "desc" }],
+ });
+
+ const sorted = [...notifications].sort(compareNotifications);
+
+ for (const n of sorted) {
+ const interaction = n.interactions[0] ?? null;
+
+ if (interaction?.cliDismissedAt) continue;
+ if (isCliNotificationExpired(interaction, n)) continue;
+
+ const parsed = PayloadV1Schema.safeParse(n.payload);
+ if (!parsed.success) continue;
+
+ // Upsert interaction: increment showCount or create
+ if (interaction) {
+ await prisma.platformNotificationInteraction.update({
+ where: { id: interaction.id },
+ data: { showCount: { increment: 1 } },
+ });
+
+ return {
+ id: n.id,
+ payload: parsed.data,
+ showCount: interaction.showCount + 1,
+ firstSeenAt: interaction.firstSeenAt.toISOString(),
+ };
+ } else {
+ const newInteraction = await prisma.platformNotificationInteraction.create({
+ data: {
+ notificationId: n.id,
+ userId,
+ firstSeenAt: now,
+ showCount: 1,
+ },
+ });
+
+ return {
+ id: n.id,
+ payload: parsed.data,
+ showCount: 1,
+ firstSeenAt: newInteraction.firstSeenAt.toISOString(),
+ };
+ }
+ }
+
+ return null;
+}
+
+// --- Create: admin endpoint support ---
+
+const SCOPE_REQUIRED_FK: Record = {
+ USER: "userId",
+ ORGANIZATION: "organizationId",
+ PROJECT: "projectId",
+};
+
+const ALL_FK_FIELDS = ["userId", "organizationId", "projectId"] as const;
+const CLI_ONLY_FIELDS = ["cliMaxDaysAfterFirstSeen", "cliMaxShowCount"] as const;
+
+export const CreatePlatformNotificationSchema = z
+ .object({
+ title: z.string().min(1),
+ payload: PayloadV1Schema,
+ surface: z.enum(["WEBAPP", "CLI"]),
+ scope: z.enum(["USER", "PROJECT", "ORGANIZATION", "GLOBAL"]),
+ userId: z.string().optional(),
+ organizationId: z.string().optional(),
+ projectId: z.string().optional(),
+ startsAt: z
+ .string()
+ .datetime()
+ .transform((s) => new Date(s))
+ .optional(),
+ endsAt: z
+ .string()
+ .datetime()
+ .transform((s) => new Date(s))
+ .optional(),
+ priority: z.number().int().default(0),
+ cliMaxDaysAfterFirstSeen: z.number().int().positive().optional(),
+ cliMaxShowCount: z.number().int().positive().optional(),
+ })
+ .superRefine((data, ctx) => {
+ validateScopeForeignKeys(data, ctx);
+ validateSurfaceFields(data, ctx);
+ validateStartsAt(data, ctx);
+ });
+
+function validateScopeForeignKeys(
+ data: { scope: string; userId?: string; organizationId?: string; projectId?: string },
+ ctx: z.RefinementCtx
+) {
+ const requiredFk = SCOPE_REQUIRED_FK[data.scope];
+
+ if (requiredFk && !data[requiredFk]) {
+ ctx.addIssue({
+ code: "custom",
+ message: `${requiredFk} is required when scope is ${data.scope}`,
+ path: [requiredFk],
+ });
+ }
+
+ const forbiddenFks = ALL_FK_FIELDS.filter((fk) => fk !== requiredFk);
+ for (const fk of forbiddenFks) {
+ if (data[fk]) {
+ ctx.addIssue({
+ code: "custom",
+ message: `${fk} must not be set when scope is ${data.scope}`,
+ path: [fk],
+ });
+ }
+ }
+}
+
+function validateSurfaceFields(
+ data: { surface: string; cliMaxDaysAfterFirstSeen?: number; cliMaxShowCount?: number },
+ ctx: z.RefinementCtx
+) {
+ if (data.surface !== "WEBAPP") return;
+
+ for (const field of CLI_ONLY_FIELDS) {
+ if (data[field] !== undefined) {
+ ctx.addIssue({
+ code: "custom",
+ message: `${field} is not allowed for WEBAPP surface`,
+ path: [field],
+ });
+ }
+ }
+}
+
+function validateStartsAt(data: { startsAt?: Date }, ctx: z.RefinementCtx) {
+ if (!data.startsAt) return;
+
+ const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);
+ if (data.startsAt < oneHourAgo) {
+ ctx.addIssue({
+ code: "custom",
+ message: "startsAt must be within the last hour or in the future",
+ path: ["startsAt"],
+ });
+ }
+}
+
+export type CreatePlatformNotificationInput = z.input;
+
+type CreateError =
+ | { type: "validation"; issues: z.ZodIssue[] }
+ | { type: "db"; message: string };
+
+export function createPlatformNotification(
+ input: CreatePlatformNotificationInput
+): ResultAsync<{ id: string; friendlyId: string }, CreateError> {
+ const parseResult = CreatePlatformNotificationSchema.safeParse(input);
+
+ if (!parseResult.success) {
+ return errAsync({ type: "validation", issues: parseResult.error.issues });
+ }
+
+ const data = parseResult.data;
+
+ return fromPromise(
+ prisma.platformNotification.create({
+ data: {
+ title: data.title,
+ payload: data.payload,
+ surface: data.surface as PlatformNotificationSurface,
+ scope: data.scope as PlatformNotificationScope,
+ userId: data.userId,
+ organizationId: data.organizationId,
+ projectId: data.projectId,
+ startsAt: data.startsAt ?? new Date(),
+ endsAt: data.endsAt,
+ priority: data.priority,
+ cliMaxDaysAfterFirstSeen: data.cliMaxDaysAfterFirstSeen,
+ cliMaxShowCount: data.cliMaxShowCount,
+ },
+ select: { id: true, friendlyId: true },
+ }),
+ (e): CreateError => ({
+ type: "db",
+ message: e instanceof Error ? e.message : String(e),
+ })
+ );
+}
diff --git a/apps/webapp/package.json b/apps/webapp/package.json
index 139a0ce2d0..f09e0fe209 100644
--- a/apps/webapp/package.json
+++ b/apps/webapp/package.json
@@ -189,6 +189,7 @@
"react-dom": "^18.2.0",
"react-grid-layout": "^2.2.2",
"react-hotkeys-hook": "^4.4.1",
+ "react-markdown": "^10.1.0",
"react-popper": "^2.3.0",
"react-resizable": "^3.1.3",
"react-resizable-panels": "^2.0.9",
diff --git a/internal-packages/database/prisma/migrations/20260223123615_add_platform_notification_tables/migration.sql b/internal-packages/database/prisma/migrations/20260223123615_add_platform_notification_tables/migration.sql
new file mode 100644
index 0000000000..0b29b6400d
--- /dev/null
+++ b/internal-packages/database/prisma/migrations/20260223123615_add_platform_notification_tables/migration.sql
@@ -0,0 +1,67 @@
+-- CreateEnum
+CREATE TYPE "public"."PlatformNotificationSurface" AS ENUM ('WEBAPP', 'CLI');
+
+-- CreateEnum
+CREATE TYPE "public"."PlatformNotificationScope" AS ENUM ('USER', 'PROJECT', 'ORGANIZATION', 'GLOBAL');
+
+-- CreateTable
+CREATE TABLE "public"."PlatformNotification" (
+ "id" TEXT NOT NULL,
+ "friendlyId" TEXT NOT NULL,
+ "title" TEXT NOT NULL,
+ "payload" JSONB NOT NULL,
+ "surface" "public"."PlatformNotificationSurface" NOT NULL,
+ "scope" "public"."PlatformNotificationScope" NOT NULL,
+ "userId" TEXT,
+ "organizationId" TEXT,
+ "projectId" TEXT,
+ "startsAt" TIMESTAMP(3) NOT NULL,
+ "endsAt" TIMESTAMP(3),
+ "cliMaxDaysAfterFirstSeen" INTEGER,
+ "cliMaxShowCount" INTEGER,
+ "priority" INTEGER NOT NULL DEFAULT 0,
+ "archivedAt" TIMESTAMP(3),
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "PlatformNotification_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "public"."PlatformNotificationInteraction" (
+ "id" TEXT NOT NULL,
+ "notificationId" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "firstSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "showCount" INTEGER NOT NULL DEFAULT 1,
+ "webappDismissedAt" TIMESTAMP(3),
+ "cliDismissedAt" TIMESTAMP(3),
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "PlatformNotificationInteraction_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "PlatformNotification_friendlyId_key" ON "public"."PlatformNotification"("friendlyId");
+
+-- CreateIndex
+CREATE INDEX "PlatformNotification_surface_scope_startsAt_idx" ON "public"."PlatformNotification"("surface", "scope", "startsAt");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "PlatformNotificationInteraction_notificationId_userId_key" ON "public"."PlatformNotificationInteraction"("notificationId", "userId");
+
+-- AddForeignKey
+ALTER TABLE "public"."PlatformNotification" ADD CONSTRAINT "PlatformNotification_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "public"."PlatformNotification" ADD CONSTRAINT "PlatformNotification_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "public"."Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "public"."PlatformNotification" ADD CONSTRAINT "PlatformNotification_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "public"."Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "public"."PlatformNotificationInteraction" ADD CONSTRAINT "PlatformNotificationInteraction_notificationId_fkey" FOREIGN KEY ("notificationId") REFERENCES "public"."PlatformNotification"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "public"."PlatformNotificationInteraction" ADD CONSTRAINT "PlatformNotificationInteraction_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma
index f6986be42c..de17ecc280 100644
--- a/internal-packages/database/prisma/schema.prisma
+++ b/internal-packages/database/prisma/schema.prisma
@@ -65,6 +65,9 @@ model User {
impersonationsReceived ImpersonationAuditLog[] @relation("ImpersonationTarget")
customerQueries CustomerQuery[]
metricsDashboards MetricsDashboard[]
+
+ platformNotifications PlatformNotification[]
+ platformNotificationInteractions PlatformNotificationInteraction[]
}
model MfaBackupCode {
@@ -228,6 +231,8 @@ model Organization {
githubAppInstallations GithubAppInstallation[]
customerQueries CustomerQuery[]
metricsDashboards MetricsDashboard[]
+
+ platformNotifications PlatformNotification[]
}
model OrgMember {
@@ -417,6 +422,8 @@ model Project {
onboardingData Json?
taskScheduleInstances TaskScheduleInstance[]
metricsDashboards MetricsDashboard[]
+
+ platformNotifications PlatformNotification[]
}
enum ProjectVersion {
@@ -2577,3 +2584,96 @@ model MetricsDashboard {
/// Fast lookup for the list
@@index([projectId, createdAt(sort: Desc)])
}
+
+enum PlatformNotificationSurface {
+ WEBAPP
+ CLI
+}
+
+enum PlatformNotificationScope {
+ USER
+ PROJECT
+ ORGANIZATION
+ GLOBAL
+}
+
+/// Admin-created notification definitions
+model PlatformNotification {
+ id String @id @default(cuid())
+
+ friendlyId String @unique @default(cuid())
+
+ /// Admin-facing title for identification in admin tools
+ title String
+
+ /// Versioned JSON rendering content (see payload schema in spec)
+ payload Json
+
+ surface PlatformNotificationSurface
+ scope PlatformNotificationScope
+
+ /// Set when scope = USER
+ user User? @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
+ userId String?
+
+ /// Set when scope = ORGANIZATION
+ organization Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade, onUpdate: Cascade)
+ organizationId String?
+
+ /// Set when scope = PROJECT
+ project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade)
+ projectId String?
+
+ /// When notification becomes active
+ startsAt DateTime
+
+ /// When notification expires. Null = persists until dismissed or archived
+ endsAt DateTime?
+
+ /// CLI: auto-expire N days after user first saw it
+ cliMaxDaysAfterFirstSeen Int?
+
+ /// CLI: auto-expire after shown N times to user
+ cliMaxShowCount Int?
+
+ /// Ordering within same scope level (higher = more important)
+ priority Int @default(0)
+
+ /// Soft delete
+ archivedAt DateTime?
+
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ interactions PlatformNotificationInteraction[]
+
+ @@index([surface, scope, startsAt])
+}
+
+/// Per-user tracking of notification views and dismissals
+model PlatformNotificationInteraction {
+ id String @id @default(cuid())
+
+ notification PlatformNotification @relation(fields: [notificationId], references: [id], onDelete: Cascade, onUpdate: Cascade)
+ notificationId String
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
+ userId String
+
+ /// Set by beacon/CLI GET on first impression
+ firstSeenAt DateTime @default(now())
+
+ /// Times shown (incremented per beacon/CLI view)
+ showCount Int @default(1)
+
+ /// User dismissed in webapp
+ webappDismissedAt DateTime?
+
+ /// Auto-dismissed or expired in CLI
+ cliDismissedAt DateTime?
+
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ @@unique([notificationId, userId])
+}
diff --git a/packages/cli-v3/src/apiClient.ts b/packages/cli-v3/src/apiClient.ts
index 693b48d992..5cb4204687 100644
--- a/packages/cli-v3/src/apiClient.ts
+++ b/packages/cli-v3/src/apiClient.ts
@@ -58,6 +58,33 @@ import { z } from "zod";
import { logger } from "./utilities/logger.js";
import { VERSION } from "./version.js";
+const CliPlatformNotificationResponseSchema = z.object({
+ notification: z
+ .object({
+ id: z.string(),
+ payload: z.object({
+ version: z.string(),
+ data: z.object({
+ type: z.enum(["info", "warn", "error", "success"]),
+ title: z.string(),
+ description: z.string(),
+ actionLabel: z.string().optional(),
+ actionUrl: z.string().optional(),
+ discovery: z
+ .object({
+ filePatterns: z.array(z.string()),
+ contentPattern: z.string().optional(),
+ matchBehavior: z.enum(["show-if-found", "show-if-not-found"]),
+ })
+ .optional(),
+ }),
+ }),
+ showCount: z.number(),
+ firstSeenAt: z.string(),
+ })
+ .nullable(),
+});
+
export class CliApiClient {
private engineURL: string;
@@ -537,6 +564,25 @@ export class CliApiClient {
);
}
+ async getCliPlatformNotification(projectRef?: string, signal?: AbortSignal) {
+ if (!this.accessToken) {
+ return { success: true as const, data: { notification: null } };
+ }
+
+ const url = new URL("/api/v1/platform-notifications", this.apiURL);
+ if (projectRef) {
+ url.searchParams.set("projectRef", projectRef);
+ }
+
+ return wrapZodFetch(CliPlatformNotificationResponseSchema, url.href, {
+ headers: {
+ Authorization: `Bearer ${this.accessToken}`,
+ "Content-Type": "application/json",
+ },
+ signal,
+ });
+ }
+
async triggerTaskRun(taskId: string, body?: TriggerTaskRequestBody) {
if (!this.accessToken) {
throw new Error("triggerTaskRun: No access token");
diff --git a/packages/cli-v3/src/commands/dev.ts b/packages/cli-v3/src/commands/dev.ts
index 5557e59581..73e79933dd 100644
--- a/packages/cli-v3/src/commands/dev.ts
+++ b/packages/cli-v3/src/commands/dev.ts
@@ -1,6 +1,7 @@
import { ResolvedConfig } from "@trigger.dev/core/v3/build";
import { Command, Option as CommandOption } from "commander";
import { z } from "zod";
+import { CliApiClient } from "../apiClient.js";
import { CommonCommandOptions, commonOptions, wrapCommandAction } from "../cli/common.js";
import { watchConfig } from "../config.js";
import { DevSessionInstance, startDevSession } from "../dev/devSession.js";
@@ -9,6 +10,10 @@ import { chalkError } from "../utilities/cliOutput.js";
import { resolveLocalEnvVars } from "../utilities/localEnvVars.js";
import { printDevBanner, printStandloneInitialBanner } from "../utilities/initialBanner.js";
import { logger } from "../utilities/logger.js";
+import {
+ awaitAndDisplayPlatformNotification,
+ fetchPlatformNotification,
+} from "../utilities/platformNotifications.js";
import { runtimeChecks } from "../utilities/runtimeCheck.js";
import { getProjectClient, LoginResultOk } from "../utilities/session.js";
import { login } from "./login.js";
@@ -28,6 +33,7 @@ const DevCommandOptions = CommonCommandOptions.extend({
config: z.string().optional(),
projectRef: z.string().optional(),
skipUpdateCheck: z.boolean().default(false),
+ skipPlatformNotifications: z.boolean().default(false),
envFile: z.string().optional(),
keepTmpFiles: z.boolean().default(false),
maxConcurrentRuns: z.coerce.number().optional(),
@@ -97,6 +103,12 @@ export function configureDevCommand(program: Command) {
).hideHelp()
)
.addOption(new CommandOption("--disable-warnings", "Suppress warnings output").hideHelp())
+ .addOption(
+ new CommandOption(
+ "--skip-platform-notifications",
+ "Skip showing platform notifications"
+ ).hideHelp()
+ )
).action(async (options) => {
wrapCommandAction("dev", DevCommandOptions, options, async (opts) => {
await devCommand(opts);
@@ -205,8 +217,17 @@ async function startDev(options: StartDevOptions) {
logger.loggerLevel = options.logLevel;
}
+ const notificationPromise = options.skipPlatformNotifications
+ ? undefined
+ : fetchPlatformNotification({
+ apiClient: new CliApiClient(options.login.auth.apiUrl, options.login.auth.accessToken),
+ projectRef: options.projectRef,
+ });
+
await printStandloneInitialBanner(true, options.profile);
+ await awaitAndDisplayPlatformNotification(notificationPromise);
+
let displayedUpdateMessage = false;
if (!options.skipUpdateCheck) {
diff --git a/packages/cli-v3/src/commands/login.ts b/packages/cli-v3/src/commands/login.ts
index f3b46405a7..df195bca69 100644
--- a/packages/cli-v3/src/commands/login.ts
+++ b/packages/cli-v3/src/commands/login.ts
@@ -20,6 +20,10 @@ import {
writeAuthConfigCurrentProfileName,
} from "../utilities/configFiles.js";
import { printInitialBanner } from "../utilities/initialBanner.js";
+import {
+ awaitAndDisplayPlatformNotification,
+ fetchPlatformNotification,
+} from "../utilities/platformNotifications.js";
import { LoginResult } from "../utilities/session.js";
import { whoAmI } from "./whoami.js";
import { logger } from "../utilities/logger.js";
@@ -285,6 +289,11 @@ export async function login(options?: LoginOptions): Promise {
options?.profile
);
+ // Fetch platform notification in parallel with whoAmI
+ const notificationPromise = fetchPlatformNotification({
+ apiClient: new CliApiClient(authConfig?.apiUrl ?? opts.defaultApiUrl, indexResult.token),
+ });
+
const whoAmIResult = await whoAmI(
{
profile: options?.profile ?? "default",
@@ -309,6 +318,8 @@ export async function login(options?: LoginOptions): Promise {
outro("Logged in successfully");
}
+ await awaitAndDisplayPlatformNotification(notificationPromise);
+
span.end();
return {
diff --git a/packages/cli-v3/src/utilities/discoveryCheck.test.ts b/packages/cli-v3/src/utilities/discoveryCheck.test.ts
new file mode 100644
index 0000000000..8c4b75420c
--- /dev/null
+++ b/packages/cli-v3/src/utilities/discoveryCheck.test.ts
@@ -0,0 +1,212 @@
+import { describe, it, expect, beforeEach, afterEach } from "vitest";
+import path from "node:path";
+import fs from "node:fs/promises";
+import os from "node:os";
+import { evaluateDiscovery, type DiscoverySpec } from "./discoveryCheck.js";
+
+let tmpDir: string;
+
+beforeEach(async () => {
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "discovery-test-"));
+});
+
+afterEach(async () => {
+ await fs.rm(tmpDir, { recursive: true, force: true });
+});
+
+describe("evaluateDiscovery", () => {
+ describe("show-if-found with file existence", () => {
+ it("returns true when file exists", async () => {
+ await fs.writeFile(path.join(tmpDir, "trigger.config.ts"), "export default {}");
+
+ const spec: DiscoverySpec = {
+ filePatterns: ["trigger.config.ts"],
+ matchBehavior: "show-if-found",
+ };
+
+ expect(await evaluateDiscovery(spec, tmpDir)).toBe(true);
+ });
+
+ it("returns false when file does not exist", async () => {
+ const spec: DiscoverySpec = {
+ filePatterns: ["trigger.config.ts"],
+ matchBehavior: "show-if-found",
+ };
+
+ expect(await evaluateDiscovery(spec, tmpDir)).toBe(false);
+ });
+ });
+
+ describe("show-if-not-found with file existence", () => {
+ it("returns false when file exists", async () => {
+ await fs.writeFile(path.join(tmpDir, ".mcp.json"), "{}");
+
+ const spec: DiscoverySpec = {
+ filePatterns: [".mcp.json"],
+ matchBehavior: "show-if-not-found",
+ };
+
+ expect(await evaluateDiscovery(spec, tmpDir)).toBe(false);
+ });
+
+ it("returns true when file does not exist", async () => {
+ const spec: DiscoverySpec = {
+ filePatterns: [".mcp.json"],
+ matchBehavior: "show-if-not-found",
+ };
+
+ expect(await evaluateDiscovery(spec, tmpDir)).toBe(true);
+ });
+ });
+
+ describe("content pattern matching", () => {
+ it("show-if-found: returns true when content matches", async () => {
+ await fs.writeFile(
+ path.join(tmpDir, "trigger.config.ts"),
+ 'import { syncVercelEnvVars } from "@trigger.dev/build";'
+ );
+
+ const spec: DiscoverySpec = {
+ filePatterns: ["trigger.config.ts"],
+ contentPattern: "syncVercelEnvVars",
+ matchBehavior: "show-if-found",
+ };
+
+ expect(await evaluateDiscovery(spec, tmpDir)).toBe(true);
+ });
+
+ it("show-if-found: returns false when file exists but content does not match", async () => {
+ await fs.writeFile(path.join(tmpDir, "trigger.config.ts"), "export default {}");
+
+ const spec: DiscoverySpec = {
+ filePatterns: ["trigger.config.ts"],
+ contentPattern: "syncVercelEnvVars",
+ matchBehavior: "show-if-found",
+ };
+
+ expect(await evaluateDiscovery(spec, tmpDir)).toBe(false);
+ });
+
+ it("show-if-not-found: returns true when file exists but content does not match", async () => {
+ await fs.writeFile(path.join(tmpDir, ".mcp.json"), '{"mcpServers": {}}');
+
+ const spec: DiscoverySpec = {
+ filePatterns: [".mcp.json"],
+ contentPattern: "trigger",
+ matchBehavior: "show-if-not-found",
+ };
+
+ expect(await evaluateDiscovery(spec, tmpDir)).toBe(true);
+ });
+
+ it("show-if-not-found: returns false when content matches", async () => {
+ await fs.writeFile(
+ path.join(tmpDir, ".mcp.json"),
+ '{"mcpServers": {"trigger": {"url": "https://mcp.trigger.dev"}}}'
+ );
+
+ const spec: DiscoverySpec = {
+ filePatterns: [".mcp.json"],
+ contentPattern: "trigger",
+ matchBehavior: "show-if-not-found",
+ };
+
+ expect(await evaluateDiscovery(spec, tmpDir)).toBe(false);
+ });
+
+ it("supports regex content patterns", async () => {
+ await fs.writeFile(path.join(tmpDir, "config.ts"), "syncVercelEnvVars({ foo: true })");
+
+ const spec: DiscoverySpec = {
+ filePatterns: ["config.ts"],
+ contentPattern: "syncVercel\\w+",
+ matchBehavior: "show-if-found",
+ };
+
+ expect(await evaluateDiscovery(spec, tmpDir)).toBe(true);
+ });
+ });
+
+ describe("glob patterns", () => {
+ it("matches files with glob patterns", async () => {
+ await fs.writeFile(path.join(tmpDir, "trigger.config.ts"), "export default {}");
+
+ const spec: DiscoverySpec = {
+ filePatterns: ["trigger.config.*"],
+ matchBehavior: "show-if-found",
+ };
+
+ expect(await evaluateDiscovery(spec, tmpDir)).toBe(true);
+ });
+
+ it("matches files in subdirectories with glob", async () => {
+ await fs.mkdir(path.join(tmpDir, ".cursor"), { recursive: true });
+ await fs.writeFile(path.join(tmpDir, ".cursor", "mcp.json"), "{}");
+
+ const spec: DiscoverySpec = {
+ filePatterns: [".cursor/mcp.json"],
+ matchBehavior: "show-if-found",
+ };
+
+ expect(await evaluateDiscovery(spec, tmpDir)).toBe(true);
+ });
+ });
+
+ describe("multiple file patterns", () => {
+ it("returns true if any pattern matches (show-if-found)", async () => {
+ await fs.writeFile(path.join(tmpDir, "trigger.config.js"), "module.exports = {}");
+
+ const spec: DiscoverySpec = {
+ filePatterns: ["trigger.config.ts", "trigger.config.js"],
+ matchBehavior: "show-if-found",
+ };
+
+ expect(await evaluateDiscovery(spec, tmpDir)).toBe(true);
+ });
+
+ it("returns true only if no pattern matches (show-if-not-found)", async () => {
+ const spec: DiscoverySpec = {
+ filePatterns: [".mcp.json", ".cursor/mcp.json"],
+ matchBehavior: "show-if-not-found",
+ };
+
+ expect(await evaluateDiscovery(spec, tmpDir)).toBe(true);
+ });
+
+ it("content match short-circuits on first matching file", async () => {
+ await fs.writeFile(path.join(tmpDir, "a.ts"), "no match here");
+ await fs.writeFile(path.join(tmpDir, "b.ts"), "syncVercelEnvVars found");
+
+ const spec: DiscoverySpec = {
+ filePatterns: ["a.ts", "b.ts"],
+ contentPattern: "syncVercelEnvVars",
+ matchBehavior: "show-if-found",
+ };
+
+ expect(await evaluateDiscovery(spec, tmpDir)).toBe(true);
+ });
+ });
+
+ describe("error handling (fail closed)", () => {
+ it("returns false when file cannot be read for content check", async () => {
+ // Create a file then make it unreadable
+ const filePath = path.join(tmpDir, "unreadable.ts");
+ await fs.writeFile(filePath, "content");
+ await fs.chmod(filePath, 0o000);
+
+ const spec: DiscoverySpec = {
+ filePatterns: ["unreadable.ts"],
+ contentPattern: "content",
+ matchBehavior: "show-if-found",
+ };
+
+ // On some systems (e.g., running as root), chmod may not restrict reads
+ // So we just verify it doesn't throw
+ const result = await evaluateDiscovery(spec, tmpDir);
+ expect(typeof result).toBe("boolean");
+
+ // Restore permissions for cleanup
+ await fs.chmod(filePath, 0o644);
+ });
+ });
+});
diff --git a/packages/cli-v3/src/utilities/discoveryCheck.ts b/packages/cli-v3/src/utilities/discoveryCheck.ts
new file mode 100644
index 0000000000..d3170065c8
--- /dev/null
+++ b/packages/cli-v3/src/utilities/discoveryCheck.ts
@@ -0,0 +1,155 @@
+import { glob, isDynamicPattern } from "tinyglobby";
+import { tryCatch } from "@trigger.dev/core/utils";
+import { expandTilde, pathExists, readFile } from "./fileSystem.js";
+import { logger } from "./logger.js";
+import path from "node:path";
+
+export type DiscoverySpec = {
+ filePatterns: string[];
+ contentPattern?: string;
+ matchBehavior: "show-if-found" | "show-if-not-found";
+};
+
+const REGEX_METACHARACTERS = /[\\^$.|?*+(){}[\]]/;
+
+/**
+ * Evaluates a discovery spec against the local filesystem.
+ * Returns `true` if the notification should be shown, `false` otherwise.
+ * Fails closed: any error returns `false` (suppress notification).
+ */
+export async function evaluateDiscovery(
+ spec: DiscoverySpec,
+ projectRoot: string
+): Promise {
+ const [error, result] = await tryCatch(doEvaluate(spec, projectRoot));
+
+ if (error) {
+ logger.debug("Discovery check failed, suppressing notification", { error });
+ return false;
+ }
+
+ return result;
+}
+
+async function doEvaluate(spec: DiscoverySpec, projectRoot: string): Promise {
+ logger.debug("Discovery: starting evaluation", {
+ filePatterns: spec.filePatterns,
+ contentPattern: spec.contentPattern,
+ matchBehavior: spec.matchBehavior,
+ projectRoot,
+ });
+
+ const matchedFiles = await resolveFilePatterns(spec.filePatterns, projectRoot);
+ const hasFileMatch = matchedFiles.length > 0;
+
+ if (!hasFileMatch) {
+ const result = spec.matchBehavior === "show-if-not-found";
+ logger.debug("Discovery: no files matched any pattern", { result });
+ return result;
+ }
+
+ // Files matched — if no content pattern, decide based on file match alone
+ if (!spec.contentPattern) {
+ const result = spec.matchBehavior === "show-if-found";
+ logger.debug("Discovery: files matched, no content pattern to check", {
+ matchedFiles,
+ result,
+ });
+ return result;
+ }
+
+ // Check content in matched files
+ const hasContentMatch = await checkContentPattern(matchedFiles, spec.contentPattern);
+
+ const result =
+ spec.matchBehavior === "show-if-found" ? hasContentMatch : !hasContentMatch;
+
+ logger.debug("Discovery: evaluation complete", {
+ matchedFiles,
+ contentPattern: spec.contentPattern,
+ hasContentMatch,
+ result,
+ });
+
+ return result;
+}
+
+async function resolveFilePatterns(
+ patterns: string[],
+ projectRoot: string
+): Promise {
+ const matched: string[] = [];
+
+ for (const pattern of patterns) {
+ const isHomeDirPattern = pattern.startsWith("~/");
+ const resolvedPattern = isHomeDirPattern ? expandTilde(pattern) : pattern;
+ const cwd = isHomeDirPattern ? "/" : projectRoot;
+ const isGlob = isDynamicPattern(resolvedPattern);
+
+ logger.debug("Discovery: resolving pattern", {
+ pattern,
+ resolvedPattern,
+ cwd,
+ isGlob,
+ isHomeDirPattern,
+ });
+
+ if (isGlob) {
+ const files = await glob({
+ patterns: [resolvedPattern],
+ cwd,
+ absolute: true,
+ dot: true,
+ });
+ if (files.length > 0) {
+ logger.debug("Discovery: glob matched files", { pattern, files });
+ }
+ matched.push(...files);
+ } else {
+ const absolutePath = isHomeDirPattern
+ ? resolvedPattern
+ : path.resolve(projectRoot, resolvedPattern);
+ const exists = await pathExists(absolutePath);
+ logger.debug("Discovery: literal path check", { pattern, absolutePath, exists });
+ if (exists) {
+ matched.push(absolutePath);
+ }
+ }
+ }
+
+ return matched;
+}
+
+async function checkContentPattern(
+ files: string[],
+ contentPattern: string
+): Promise {
+ const useFastPath = !REGEX_METACHARACTERS.test(contentPattern);
+
+ logger.debug("Discovery: checking content pattern", {
+ contentPattern,
+ useFastPath,
+ fileCount: files.length,
+ });
+
+ for (const filePath of files) {
+ const [error, content] = await tryCatch(readFile(filePath));
+
+ if (error) {
+ logger.debug("Discovery: failed to read file, skipping", { filePath, error });
+ continue;
+ }
+
+ const matches = useFastPath
+ ? content.includes(contentPattern)
+ : new RegExp(contentPattern).test(content);
+
+ logger.debug("Discovery: content check result", { filePath, matches });
+
+ if (matches) {
+ return true;
+ }
+ }
+
+ return false;
+}
diff --git a/packages/cli-v3/src/utilities/platformNotifications.ts b/packages/cli-v3/src/utilities/platformNotifications.ts
new file mode 100644
index 0000000000..2a2fe4ed2c
--- /dev/null
+++ b/packages/cli-v3/src/utilities/platformNotifications.ts
@@ -0,0 +1,117 @@
+import { log } from "@clack/prompts";
+import chalk from "chalk";
+import { tryCatch } from "@trigger.dev/core/utils";
+import { CliApiClient } from "../apiClient.js";
+import { chalkGrey } from "./cliOutput.js";
+import { evaluateDiscovery } from "./discoveryCheck.js";
+import { logger } from "./logger.js";
+import { spinner } from "./windows.js";
+
+type CliLogLevel = "info" | "warn" | "error" | "success";
+
+type PlatformNotification = {
+ level: CliLogLevel;
+ title: string;
+ description: string;
+ actionUrl?: string;
+};
+
+type FetchNotificationOptions = {
+ apiClient: CliApiClient;
+ projectRef?: string;
+ projectRoot?: string;
+};
+
+export async function fetchPlatformNotification(
+ options: FetchNotificationOptions
+): Promise {
+ const [error, result] = await tryCatch(
+ options.apiClient.getCliPlatformNotification(
+ options.projectRef,
+ AbortSignal.timeout(7000)
+ )
+ );
+
+ if (error) {
+ logger.debug("Platform notifications failed silently", { error });
+ return undefined;
+ }
+
+ if (!result.success) {
+ logger.debug("Platform notification fetch failed", { result });
+ return undefined;
+ }
+
+ const notification = result.data.notification;
+ if (!notification) return undefined;
+
+ const { type, discovery, title, description, actionUrl } = notification.payload.data;
+
+ if (discovery) {
+ const root = options.projectRoot ?? process.cwd();
+ const shouldShow = await evaluateDiscovery(discovery, root);
+ if (!shouldShow) {
+ logger.debug("Notification suppressed by discovery check", {
+ notificationId: notification.id,
+ discovery,
+ });
+ return undefined;
+ }
+ }
+
+ return { level: type, title, description, actionUrl };
+}
+
+function displayPlatformNotification(
+ notification: PlatformNotification | undefined
+): void {
+ if (!notification) return;
+
+ const message = formatNotificationMessage(notification);
+ log[notification.level](message);
+}
+
+function formatNotificationMessage(notification: PlatformNotification): string {
+ const { title, description, actionUrl } = notification;
+ const lines = [chalk.bold(title), chalkGrey(description)];
+ if (actionUrl) {
+ lines.push(chalk.underline(chalkGrey(actionUrl)));
+ }
+ return lines.join("\n");
+}
+
+const SPINNER_DELAY_MS = 200;
+
+/**
+ * Awaits a notification promise, showing a loading spinner if the fetch
+ * takes longer than 200ms. The spinner is replaced by the notification
+ * content, or removed cleanly if there's nothing to show.
+ */
+export async function awaitAndDisplayPlatformNotification(
+ notificationPromise: Promise | undefined
+): Promise {
+ if (!notificationPromise) return;
+
+ // Race against a short delay — if the promise resolves quickly, skip the spinner
+ const pending = Symbol("pending");
+ const raceResult = await Promise.race([
+ notificationPromise,
+ new Promise((resolve) => setTimeout(() => resolve(pending), SPINNER_DELAY_MS)),
+ ]);
+
+ if (raceResult !== pending) {
+ displayPlatformNotification(raceResult);
+ return;
+ }
+
+ // Still pending after delay — show a spinner while waiting
+ const $spinner = spinner();
+ $spinner.start("Checking for notifications");
+ const notification = await notificationPromise;
+
+ if (notification) {
+ $spinner.stop(formatNotificationMessage(notification));
+ } else {
+ $spinner.stop("No new notifications");
+ }
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 88ac6ad542..2768d48ffd 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -704,6 +704,9 @@ importers:
react-hotkeys-hook:
specifier: ^4.4.1
version: 4.4.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
+ react-markdown:
+ specifier: ^10.1.0
+ version: 10.1.0(@types/react@18.2.69)(react@18.2.0)
react-popper:
specifier: ^2.3.0
version: 2.3.0(@popperjs/core@2.11.8)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)