diff --git a/backend/apps/workflows/workflows.py b/backend/apps/workflows/workflows.py
index f5e2e1a4..db530286 100644
--- a/backend/apps/workflows/workflows.py
+++ b/backend/apps/workflows/workflows.py
@@ -771,6 +771,16 @@ async def get_workflow_audit(workflow_id: str, limit: int = 50):
return {"entries": audit.read_tail(workflow_id, limit=limit)}
+@workflows.router.get("/{workflow_id}/events")
+async def get_workflow_events(workflow_id: str):
+ """Event-trigger activity log, newest first ("saw X, skipped because Y, fired run Z")."""
+ wf = storage.get_workflow(workflow_id)
+ if not wf:
+ raise HTTPException(status_code=404, detail="Workflow not found")
+ from backend.apps.events.stores import read_log
+ return {"events": [e.model_dump(mode="json") for e in reversed(read_log(workflow_id))]}
+
+
@workflows.router.patch("/{workflow_id}")
async def update_workflow(
workflow_id: str,
diff --git a/frontend/src/app/pages/Workflows/app/DetailView.tsx b/frontend/src/app/pages/Workflows/app/DetailView.tsx
index cf0e290b..f102d7f4 100644
--- a/frontend/src/app/pages/Workflows/app/DetailView.tsx
+++ b/frontend/src/app/pages/Workflows/app/DetailView.tsx
@@ -12,6 +12,7 @@ import { isRunning, runContextChip } from './model';
import { useEditAgentSession } from './useEditAgentSession';
import { useWorkflowPatch } from './useWorkflowPatch';
import ScheduleCard from './ScheduleCard';
+import EventTriggersCard from './EventTriggersCard';
import StepsCard from './StepsCard';
import HistoryCard from './HistoryCard';
import ColorSwatch from './ColorSwatch';
@@ -107,6 +108,7 @@ const DetailView: React.FC<{ workflowId: string; nav: AppNav }> = ({ workflowId
+
diff --git a/frontend/src/app/pages/Workflows/app/EventTriggersCard.tsx b/frontend/src/app/pages/Workflows/app/EventTriggersCard.tsx
new file mode 100644
index 00000000..f78a0a12
--- /dev/null
+++ b/frontend/src/app/pages/Workflows/app/EventTriggersCard.tsx
@@ -0,0 +1,211 @@
+// "When something happens" panel beside the Schedule card: watch a folder/file
+// or a web page, optionally filter with a plain-English condition, and see the
+// trigger's recent activity ("saw X, skipped because Y") so a quiet trigger is
+// debuggable instead of mysterious.
+
+import React, { useCallback, useEffect, useState } from 'react';
+import type { CSSProperties } from 'react';
+import { API_BASE } from '@/shared/config';
+import type { EventTriggerConfig, Workflow, WorkflowEventLogEntry } from '@/shared/state/workflowsSlice';
+import { useWC, FONT_SERIF, track, knob } from './uiKit';
+import { useWorkflowPatch } from './useWorkflowPatch';
+
+const WEB_POLL_CHOICES: Array<[number, string]> = [[60, 'every minute'], [300, 'every 5 min'], [900, 'every 15 min'], [3600, 'hourly']];
+
+function newTrigger(kind: 'file' | 'web'): EventTriggerConfig {
+ return {
+ id: crypto.randomUUID().replace(/-/g, ''),
+ enabled: true,
+ source: kind === 'file'
+ ? { kind: 'file', path: '', poll_seconds: 15 }
+ : { kind: 'web', url: '', watch_for: '', poll_seconds: 300 },
+ predicate: '',
+ coalesce_seconds: kind === 'file' ? 30 : 0,
+ max_fires_per_hour: 6,
+ };
+}
+
+const EventTriggersCard: React.FC<{ workflow: Workflow }> = ({ workflow }) => {
+ const WC = useWC();
+ const patch = useWorkflowPatch();
+ const triggers = workflow.event_triggers ?? [];
+ const [log, setLog] = useState
([]);
+
+ const patchTriggers = useCallback((next: EventTriggerConfig[]) => {
+ patch(workflow, { event_triggers: next });
+ }, [patch, workflow]);
+
+ const updateTrigger = useCallback((id: string, mut: (t: EventTriggerConfig) => EventTriggerConfig) => {
+ patchTriggers(triggers.map((t) => (t.id === id ? mut(t) : t)));
+ }, [patchTriggers, triggers]);
+
+ // Activity poll while the panel is mounted; local endpoint, cheap.
+ useEffect(() => {
+ if (triggers.length === 0) return;
+ let alive = true;
+ const load = async () => {
+ try {
+ const r = await fetch(`${API_BASE}/workflows/${workflow.id}/events`);
+ const data = (await r.json()) as { events: WorkflowEventLogEntry[] };
+ if (alive) setLog(data.events ?? []);
+ } catch { /* activity is best-effort */ }
+ };
+ void load();
+ const iv = setInterval(load, 15_000);
+ return () => { alive = false; clearInterval(iv); };
+ }, [workflow.id, triggers.length]);
+
+ const ghostBtn: CSSProperties = {
+ height: 26, padding: '0 10px', borderRadius: 7, border: `1px solid rgba(${WC.inkRGB},0.12)`,
+ cursor: 'pointer', fontSize: 11.5, fontWeight: 600, background: WC.raised, color: WC.ink3,
+ };
+ const fieldStyle: CSSProperties = {
+ width: '100%', boxSizing: 'border-box', height: 30, background: WC.raised,
+ border: `1px solid rgba(${WC.inkRGB},0.12)`, borderRadius: 8, padding: '0 9px',
+ fontSize: 12.5, color: WC.ink,
+ };
+ const labelStyle: CSSProperties = { fontSize: 11.5, color: WC.muted, marginBottom: 4, display: 'block' };
+
+ const dotColor = (kind: WorkflowEventLogEntry['kind']): string => {
+ if (kind === 'fired') return WC.accent;
+ if (kind === 'emitted') return WC.muted;
+ return WC.warn;
+ };
+
+ return (
+
+
+
Event triggers
+
+ patchTriggers([...triggers, newTrigger('file')])}>+ Folder
+ patchTriggers([...triggers, newTrigger('web')])}>+ Web page
+
+
+
+ {triggers.length === 0 && (
+
+ Run this workflow when something happens: a file lands in a folder, or a page you care about changes.
+
+ )}
+
+ {triggers.map((t) => {
+ const src = t.source;
+ return (
+
+
+
+ {src.kind === 'file' ? 'Folder / file watch' : 'Web page watch'}
+
+
+
updateTrigger(t.id, (x) => ({ ...x, enabled: !x.enabled }))}
+ style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}
+ >
+
{t.enabled ? 'On' : 'Off'}
+
+
+
patchTriggers(triggers.filter((x) => x.id !== t.id))}
+ style={{ ...ghostBtn, padding: '0 8px' }}
+ >
+ ✕
+
+
+
+
+ {src.kind === 'file' ? (
+
+ Watch this folder or file
+ {
+ const path = e.target.value.trim();
+ if (path !== src.path) updateTrigger(t.id, (x) => ({ ...x, source: { ...src, path } }));
+ }}
+ />
+
+ ) : (
+ <>
+
+ Page URL
+ {
+ const url = e.target.value.trim();
+ if (url !== src.url) updateTrigger(t.id, (x) => ({ ...x, source: { ...src, url } }));
+ }}
+ />
+
+
+
+ Watching for
+ {
+ const watchFor = e.target.value.trim();
+ if (watchFor !== src.watch_for) updateTrigger(t.id, (x) => ({ ...x, source: { ...src, watch_for: watchFor } }));
+ }}
+ />
+
+
+ Check
+ {
+ const pollSeconds = parseInt(e.target.value, 10);
+ updateTrigger(t.id, (x) => ({ ...x, source: { ...src, poll_seconds: pollSeconds } }));
+ }}
+ style={{ ...fieldStyle, cursor: 'pointer', padding: '0 6px' }}
+ >
+ {WEB_POLL_CHOICES.map(([v, label]) => {label} )}
+
+
+
+ >
+ )}
+
+
+ Only when (optional, plain English)
+ {
+ const predicate = e.target.value.trim();
+ if (predicate !== t.predicate) updateTrigger(t.id, (x) => ({ ...x, predicate }));
+ }}
+ />
+
+
+ );
+ })}
+
+ {triggers.length > 0 && log.length > 0 && (
+
+
Recent activity
+ {log.slice(0, 6).map((e, i) => (
+
+
+
+ {new Date(e.ts).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })}
+ {' '}
+ {e.summary}
+
+
+ ))}
+
+ )}
+
+ );
+};
+
+export default EventTriggersCard;
diff --git a/frontend/src/shared/state/workflowsSlice.ts b/frontend/src/shared/state/workflowsSlice.ts
index 445e0250..20b4aed2 100644
--- a/frontend/src/shared/state/workflowsSlice.ts
+++ b/frontend/src/shared/state/workflowsSlice.ts
@@ -28,6 +28,40 @@ export interface ScheduleConfig {
runs_count: number;
}
+export interface FileWatchSource {
+ kind: 'file';
+ path: string;
+ poll_seconds: number;
+}
+
+export interface WebWatchSource {
+ kind: 'web';
+ url: string;
+ /** What change actually matters, in the user's words ("a reservation slot opens"). */
+ watch_for: string;
+ poll_seconds: number;
+}
+
+export type EventSourceConfig = FileWatchSource | WebWatchSource;
+
+export interface EventTriggerConfig {
+ id: string;
+ enabled: boolean;
+ source: EventSourceConfig;
+ /** Natural-language filter judged per event batch; empty = every batch fires. */
+ predicate: string;
+ coalesce_seconds: number;
+ max_fires_per_hour: number;
+}
+
+export interface WorkflowEventLogEntry {
+ ts: string;
+ trigger_id: string;
+ kind: 'emitted' | 'fired' | 'skipped' | 'error';
+ summary: string;
+ run_id?: string | null;
+}
+
export interface CostEstimate {
monthly_usd: number;
last_run_usd: number;
@@ -74,6 +108,8 @@ export interface Workflow {
steps: WorkflowStep[];
actions: ActionsConfig;
schedule: ScheduleConfig;
+ /** Event triggers live beside the schedule; "every Monday AND when this file changes" is legitimate. */
+ event_triggers?: EventTriggerConfig[];
permissions: PermissionTier[];
source_session_id?: string | null;
dashboard_id?: string | null;