diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/automations-content.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/automations-content.tsx
new file mode 100644
index 000000000..fa1caff96
--- /dev/null
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/automations-content.tsx
@@ -0,0 +1,101 @@
+"use client";
+import { ShieldAlert } from "lucide-react";
+import { useAutomations } from "@/hooks/use-automations";
+import { AutomationsEmptyState } from "./components/automations-empty-state";
+import { AutomationsHeader } from "./components/automations-header";
+import { AutomationsTable } from "./components/automations-table";
+import { useAutomationPermissions } from "./hooks/use-automation-permissions";
+
+interface AutomationsContentProps {
+ searchSpaceId: number;
+}
+
+/**
+ * Client orchestrator for the automations list page. Pulls the active
+ * search space's first page (via ``useAutomations`` → ``automationsListAtom``)
+ * and the user's permissions, then decides between empty / loading / table.
+ *
+ * Read access is mandatory; anything else is hidden behind RBAC. The
+ * permissions hook is co-located in this slice so adding/removing
+ * surfaces is a one-file change.
+ */
+export function AutomationsContent({ searchSpaceId }: AutomationsContentProps) {
+ const { automations, total, loading, error } = useAutomations();
+ const perms = useAutomationPermissions();
+
+ if (perms.loading) {
+ // Permissions gate the entire page; defer everything until we know.
+ return (
+ <>
+
+
+ >
+ );
+ }
+
+ if (!perms.canRead) {
+ return (
+
+
+
Access denied
+
+ You don't have permission to view automations in this search space.
+
+
+ );
+ }
+
+ if (error) {
+ return (
+ <>
+
+
+
Couldn't load automations. {error.message}
+
+ >
+ );
+ }
+
+ if (!loading && automations.length === 0) {
+ return (
+ <>
+
+
+ >
+ );
+ }
+
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/automation-row-actions.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/components/automation-row-actions.tsx
new file mode 100644
index 000000000..229a417dc
--- /dev/null
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/components/automation-row-actions.tsx
@@ -0,0 +1,98 @@
+"use client";
+import { useAtomValue } from "jotai";
+import { MoreHorizontal, Pause, Play, Trash2 } from "lucide-react";
+import { useState } from "react";
+import { updateAutomationMutationAtom } from "@/atoms/automations/automations-mutation.atoms";
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import type { AutomationSummary } from "@/contracts/types/automation.types";
+import { DeleteAutomationDialog } from "./delete-automation-dialog";
+
+interface AutomationRowActionsProps {
+ automation: AutomationSummary;
+ searchSpaceId: number;
+ canUpdate: boolean;
+ canDelete: boolean;
+}
+
+/**
+ * Three-dot menu on each row: pause/resume (if updatable) and delete
+ * (if deletable). The menu itself is hidden when the user has neither
+ * permission so we don't render an empty trigger.
+ */
+export function AutomationRowActions({
+ automation,
+ searchSpaceId,
+ canUpdate,
+ canDelete,
+}: AutomationRowActionsProps) {
+ const { mutateAsync: updateAutomation, isPending: updating } = useAtomValue(
+ updateAutomationMutationAtom
+ );
+ const [deleteOpen, setDeleteOpen] = useState(false);
+
+ if (!canUpdate && !canDelete) return null;
+
+ const nextStatus = automation.status === "active" ? "paused" : "active";
+ const pauseLabel = automation.status === "active" ? "Pause" : "Resume";
+ const PauseIcon = automation.status === "active" ? Pause : Play;
+ const canToggle = canUpdate && automation.status !== "archived";
+
+ async function handleTogglePause() {
+ await updateAutomation({
+ automationId: automation.id,
+ patch: { status: nextStatus },
+ });
+ }
+
+ return (
+ <>
+
+
+
+
+
+
+
+ {canToggle && (
+
+
+ {pauseLabel}
+
+ )}
+ {canToggle && canDelete && }
+ {canDelete && (
+ setDeleteOpen(true)}
+ className="text-destructive focus:text-destructive"
+ >
+
+ Delete
+
+ )}
+
+
+
+ {canDelete && (
+
+ )}
+ >
+ );
+}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/automation-row.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/components/automation-row.tsx
new file mode 100644
index 000000000..a59fb4527
--- /dev/null
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/components/automation-row.tsx
@@ -0,0 +1,61 @@
+"use client";
+import Link from "next/link";
+import { TableCell, TableRow } from "@/components/ui/table";
+import type { AutomationSummary } from "@/contracts/types/automation.types";
+import { formatRelativeDate } from "@/lib/format-date";
+import { AutomationRowActions } from "./automation-row-actions";
+import { AutomationStatusBadge } from "./automation-status-badge";
+
+interface AutomationRowProps {
+ automation: AutomationSummary;
+ searchSpaceId: number;
+ canUpdate: boolean;
+ canDelete: boolean;
+}
+
+/**
+ * One row in the automations table. The name links to the detail page;
+ * actions are gated by ``canUpdate`` / ``canDelete``. Trigger summary
+ * is intentionally left to the detail page — list responses don't
+ * include triggers and we want to avoid N+1 detail fetches.
+ */
+export function AutomationRow({
+ automation,
+ searchSpaceId,
+ canUpdate,
+ canDelete,
+}: AutomationRowProps) {
+ return (
+
+
+
+
+ {automation.name}
+
+ {automation.description && (
+
+ {automation.description}
+
+ )}
+
+
+
+
+
+
+ {formatRelativeDate(automation.updated_at)}
+
+
+
+
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/automation-status-badge.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/components/automation-status-badge.tsx
new file mode 100644
index 000000000..ecf171e78
--- /dev/null
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/components/automation-status-badge.tsx
@@ -0,0 +1,49 @@
+"use client";
+import { Archive, CircleDot, Pause } from "lucide-react";
+import type { AutomationStatus } from "@/contracts/types/automation.types";
+import { cn } from "@/lib/utils";
+
+interface AutomationStatusBadgeProps {
+ status: AutomationStatus;
+ className?: string;
+}
+
+// Color + icon per status. Active = green, paused = amber, archived = muted.
+const STATUS_STYLES: Record<
+ AutomationStatus,
+ { label: string; icon: typeof CircleDot; classes: string }
+> = {
+ active: {
+ label: "Active",
+ icon: CircleDot,
+ classes:
+ "bg-emerald-50 text-emerald-700 border border-emerald-200 dark:bg-emerald-950/40 dark:text-emerald-300 dark:border-emerald-900/50",
+ },
+ paused: {
+ label: "Paused",
+ icon: Pause,
+ classes:
+ "bg-amber-50 text-amber-700 border border-amber-200 dark:bg-amber-950/40 dark:text-amber-300 dark:border-amber-900/50",
+ },
+ archived: {
+ label: "Archived",
+ icon: Archive,
+ classes: "bg-muted text-muted-foreground border border-border/60",
+ },
+};
+
+export function AutomationStatusBadge({ status, className }: AutomationStatusBadgeProps) {
+ const { label, icon: Icon, classes } = STATUS_STYLES[status];
+ return (
+
+
+ {label}
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/automation-triggers-summary.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/components/automation-triggers-summary.tsx
new file mode 100644
index 000000000..ac27b01e2
--- /dev/null
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/components/automation-triggers-summary.tsx
@@ -0,0 +1,120 @@
+"use client";
+import { CalendarClock, Pause } from "lucide-react";
+import type { Trigger } from "@/contracts/types/automation.types";
+
+interface AutomationTriggersSummaryProps {
+ triggers: Trigger[];
+}
+
+/**
+ * One-line summary of an automation's triggers for the list view.
+ *
+ * v1 only registers ``schedule`` so this stays compact:
+ * - 0 triggers → "No triggers"
+ * - 1 schedule trigger → "Mon–Fri at 09:00 · UTC" + disabled badge if off
+ * - >1 → "N triggers"
+ *
+ * The detail page renders the full per-trigger editor.
+ */
+export function AutomationTriggersSummary({ triggers }: AutomationTriggersSummaryProps) {
+ if (triggers.length === 0) {
+ return No triggers ;
+ }
+
+ if (triggers.length > 1) {
+ return {triggers.length} triggers ;
+ }
+
+ const [trigger] = triggers;
+
+ if (trigger.type === "schedule") {
+ const cron = typeof trigger.params.cron === "string" ? trigger.params.cron : undefined;
+ const tz = typeof trigger.params.timezone === "string" ? trigger.params.timezone : "UTC";
+ const human = cron ? describeCron(cron) : "Schedule";
+
+ return (
+
+
+ {human}
+ · {tz}
+ {!trigger.enabled && (
+
+
+ Off
+
+ )}
+
+ );
+ }
+
+ return {trigger.type} ;
+}
+
+// ----------------------------------------------------------------------------
+// Minimal cron describer for the common 5-field patterns SurfSense automations
+// surface today. Falls back to the raw expression when unrecognized so the user
+// still sees something honest instead of a guess.
+//
+// Kept inline (not a library) because:
+// - v1 only needs to recognize a small set of patterns produced by the
+// drafter LLM (hourly/daily/weekdays/weekly/monthly).
+// - All current consumers live in this slice. If reuse grows, lift to
+// ``lib/cron-describe.ts``.
+// ----------------------------------------------------------------------------
+
+const DAY_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
+
+function describeCron(cron: string): string {
+ const parts = cron.trim().split(/\s+/);
+ if (parts.length !== 5) return cron;
+
+ const [minute, hour, dom, month, dow] = parts;
+
+ // Daily at H:MM (matches the very common "0 9 * * *")
+ if (month === "*" && dom === "*" && dow === "*" && /^\d+$/.test(minute) && /^\d+$/.test(hour)) {
+ return `Daily at ${formatTime(hour, minute)}`;
+ }
+
+ // Weekdays at H:MM ("0 9 * * 1-5")
+ if (month === "*" && dom === "*" && dow === "1-5" && /^\d+$/.test(minute) && /^\d+$/.test(hour)) {
+ return `Mon–Fri at ${formatTime(hour, minute)}`;
+ }
+
+ // Specific weekday(s) ("0 9 * * 1" or "0 9 * * 1,3,5")
+ if (
+ month === "*" &&
+ dom === "*" &&
+ /^\d+$/.test(minute) &&
+ /^\d+$/.test(hour) &&
+ /^[\d,]+$/.test(dow)
+ ) {
+ const days = dow
+ .split(",")
+ .map((d) => DAY_NAMES[Number(d) % 7])
+ .filter(Boolean)
+ .join(", ");
+ if (days) return `${days} at ${formatTime(hour, minute)}`;
+ }
+
+ // Monthly on day N ("0 9 1 * *")
+ if (
+ month === "*" &&
+ dow === "*" &&
+ /^\d+$/.test(dom) &&
+ /^\d+$/.test(hour) &&
+ /^\d+$/.test(minute)
+ ) {
+ return `Day ${dom} of each month at ${formatTime(hour, minute)}`;
+ }
+
+ // Hourly ("0 * * * *")
+ if (month === "*" && dom === "*" && dow === "*" && hour === "*" && /^\d+$/.test(minute)) {
+ return minute === "0" ? "Every hour" : `Every hour at :${minute.padStart(2, "0")}`;
+ }
+
+ return cron;
+}
+
+function formatTime(hour: string, minute: string): string {
+ return `${hour.padStart(2, "0")}:${minute.padStart(2, "0")}`;
+}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-empty-state.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-empty-state.tsx
new file mode 100644
index 000000000..4004cce9b
--- /dev/null
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-empty-state.tsx
@@ -0,0 +1,42 @@
+"use client";
+import { MessageSquarePlus, Workflow } from "lucide-react";
+import Link from "next/link";
+import { Button } from "@/components/ui/button";
+
+interface AutomationsEmptyStateProps {
+ searchSpaceId: number;
+ canCreate: boolean;
+}
+
+/**
+ * Zero-state for the automations list. The primary CTA points to a new
+ * chat — creation happens via the ``create_automation`` HITL tool, not a
+ * "new automation" form. We surface the chat path explicitly so users
+ * don't go hunting for an "add" button that doesn't exist.
+ */
+export function AutomationsEmptyState({ searchSpaceId, canCreate }: AutomationsEmptyStateProps) {
+ return (
+
+
+
+
+
No automations yet
+
+ Automations let SurfSense run agent tasks on a schedule. Describe what you want in chat and
+ SurfSense drafts the automation for your approval.
+
+ {canCreate ? (
+
+
+
+ Create via chat
+
+
+ ) : (
+
+ You don't have permission to create automations in this search space.
+
+ )}
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-header.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-header.tsx
new file mode 100644
index 000000000..b938825a6
--- /dev/null
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-header.tsx
@@ -0,0 +1,44 @@
+"use client";
+import { MessageSquarePlus } from "lucide-react";
+import Link from "next/link";
+import { Button } from "@/components/ui/button";
+
+interface AutomationsHeaderProps {
+ searchSpaceId: number;
+ total: number;
+ loading: boolean;
+ canCreate: boolean;
+}
+
+/**
+ * Page header: title + count + "Create via chat" CTA. Creation is intent-driven
+ * (the create_automation tool runs inside chat with a HITL approval card), so
+ * the CTA links to a new chat rather than opening a form.
+ */
+export function AutomationsHeader({
+ searchSpaceId,
+ total,
+ loading,
+ canCreate,
+}: AutomationsHeaderProps) {
+ return (
+
+
+
Automations
+ {!loading && (
+
+ {total} {total === 1 ? "automation" : "automations"}
+
+ )}
+
+ {canCreate && (
+
+
+
+ Create via chat
+
+
+ )}
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-loading.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-loading.tsx
new file mode 100644
index 000000000..1156be3f6
--- /dev/null
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-loading.tsx
@@ -0,0 +1,36 @@
+"use client";
+import { Skeleton } from "@/components/ui/skeleton";
+import { TableCell, TableRow } from "@/components/ui/table";
+
+const ROW_KEYS = ["sk-1", "sk-2", "sk-3"];
+
+/**
+ * Skeleton rows for the automations table. Number of rows is fixed since
+ * we don't know the count ahead of time and three placeholders is enough
+ * to communicate "loading" without flashing too much chrome.
+ */
+export function AutomationsLoadingRows() {
+ return (
+ <>
+ {ROW_KEYS.map((key) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))}
+ >
+ );
+}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-table.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-table.tsx
new file mode 100644
index 000000000..ec3aeeef5
--- /dev/null
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-table.tsx
@@ -0,0 +1,73 @@
+"use client";
+import { Activity, CalendarDays, Workflow } from "lucide-react";
+import { Table, TableBody, TableHead, TableHeader, TableRow } from "@/components/ui/table";
+import type { AutomationSummary } from "@/contracts/types/automation.types";
+import { AutomationRow } from "./automation-row";
+import { AutomationsLoadingRows } from "./automations-loading";
+
+interface AutomationsTableProps {
+ automations: AutomationSummary[];
+ searchSpaceId: number;
+ loading: boolean;
+ canUpdate: boolean;
+ canDelete: boolean;
+}
+
+/**
+ * Table shell + header. Rows render below — loading state renders skeleton
+ * rows in the same shell so the layout doesn't shift on data arrival.
+ */
+export function AutomationsTable({
+ automations,
+ searchSpaceId,
+ loading,
+ canUpdate,
+ canDelete,
+}: AutomationsTableProps) {
+ return (
+
+
+
+
+
+
+
+ Name
+
+
+
+
+
+ Status
+
+
+
+
+
+ Updated
+
+
+
+ Actions
+
+
+
+
+ {loading ? (
+
+ ) : (
+ automations.map((automation) => (
+
+ ))
+ )}
+
+
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/delete-automation-dialog.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/components/delete-automation-dialog.tsx
new file mode 100644
index 000000000..db73ddad5
--- /dev/null
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/components/delete-automation-dialog.tsx
@@ -0,0 +1,80 @@
+"use client";
+import { useAtomValue } from "jotai";
+import { useState } from "react";
+import { deleteAutomationMutationAtom } from "@/atoms/automations/automations-mutation.atoms";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { Spinner } from "@/components/ui/spinner";
+
+interface DeleteAutomationDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ automationId: number;
+ automationName: string;
+ searchSpaceId: number;
+}
+
+/**
+ * Confirm + delete one automation. FK cascade on the backend wipes attached
+ * triggers and runs, so we mention it explicitly. List re-fetch is handled
+ * by the mutation atom's onSuccess.
+ */
+export function DeleteAutomationDialog({
+ open,
+ onOpenChange,
+ automationId,
+ automationName,
+ searchSpaceId,
+}: DeleteAutomationDialogProps) {
+ const { mutateAsync: deleteAutomation } = useAtomValue(deleteAutomationMutationAtom);
+ const [submitting, setSubmitting] = useState(false);
+
+ async function handleConfirm() {
+ setSubmitting(true);
+ try {
+ await deleteAutomation({ automationId, searchSpaceId });
+ onOpenChange(false);
+ } finally {
+ setSubmitting(false);
+ }
+ }
+
+ return (
+
+
+
+ Delete this automation?
+
+ {automationName} and all of its
+ triggers and run history will be removed. This cannot be undone.
+
+
+
+ Cancel
+
+ {submitting ? (
+
+
+ Deleting…
+
+ ) : (
+ "Delete"
+ )}
+
+
+
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/hooks/use-automation-permissions.ts b/surfsense_web/app/dashboard/[search_space_id]/automations/hooks/use-automation-permissions.ts
new file mode 100644
index 000000000..293688710
--- /dev/null
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/hooks/use-automation-permissions.ts
@@ -0,0 +1,37 @@
+"use client";
+import { useAtomValue } from "jotai";
+import { useMemo } from "react";
+import { canPerform, myAccessAtom } from "@/atoms/members/members-query.atoms";
+
+/**
+ * Centralized RBAC gates for the automations slice. Co-located with the
+ * route so adding/removing surfaces stays a one-file change. Backed by
+ * the same ``myAccessAtom`` the rest of the app uses; owners short-circuit
+ * to ``true`` for every action.
+ *
+ * Mirrors backend permissions in ``app.db.permissions`` (automations:*).
+ */
+export interface AutomationPermissions {
+ loading: boolean;
+ canCreate: boolean;
+ canRead: boolean;
+ canUpdate: boolean;
+ canDelete: boolean;
+ canExecute: boolean;
+}
+
+export function useAutomationPermissions(): AutomationPermissions {
+ const { data: access, isLoading } = useAtomValue(myAccessAtom);
+
+ return useMemo(
+ () => ({
+ loading: isLoading,
+ canCreate: canPerform(access, "automations:create"),
+ canRead: canPerform(access, "automations:read"),
+ canUpdate: canPerform(access, "automations:update"),
+ canDelete: canPerform(access, "automations:delete"),
+ canExecute: canPerform(access, "automations:execute"),
+ }),
+ [access, isLoading]
+ );
+}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/page.tsx
new file mode 100644
index 000000000..b77cb20f4
--- /dev/null
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/page.tsx
@@ -0,0 +1,15 @@
+import { AutomationsContent } from "./automations-content";
+
+export default async function AutomationsPage({
+ params,
+}: {
+ params: Promise<{ search_space_id: string }>;
+}) {
+ const { search_space_id } = await params;
+
+ return (
+
+ );
+}