diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py
index 9d91456b..b9248375 100644
--- a/backend/apps/agents/agent_manager.py
+++ b/backend/apps/agents/agent_manager.py
@@ -612,6 +612,12 @@ class AgentManager:
"mcp__openswarm-schedule__DeleteScheduledWorkflow",
"mcp__openswarm-schedule__PauseAllWorkflows",
}
+ _CLAUDE_INTERNAL_SCHEDULER_TOOLS = ("CronCreate", "CronList", "CronDelete")
+
+ def _is_claude_schedule_skill(tool_name: str, tool_input) -> bool:
+ if tool_name != "Skill" or not isinstance(tool_input, dict):
+ return False
+ return str(tool_input.get("skill") or "").strip().lower() == "schedule"
# OS-level scheduling across macOS/Linux/Windows. Agent must
# not install cron entries, launchd plists, Windows scheduled
@@ -732,6 +738,8 @@ class AgentManager:
"""
if tool_name == "Bash" and _looks_like_os_scheduling(tool_input):
return "ask", None
+ if tool_name in _CLAUDE_INTERNAL_SCHEDULER_TOOLS:
+ return "deny", None
# Committing or mutating a native recurring schedule is the in-app
# twin of the crontab gate above: real, user-visible, hard-to-undo,
# so it goes through ApprovalBar every time regardless of the
@@ -907,6 +915,14 @@ class AgentManager:
return decision
async def can_use_tool(tool_name, input_data, context):
+ if _is_claude_schedule_skill(tool_name, input_data):
+ p_note_tool_used(tool_name, False)
+ return PermissionResultDeny(
+ message=(
+ "Use the openswarm-schedule MCP tools instead of "
+ "Claude's internal schedule skill."
+ )
+ )
sensitive_pattern: str | None = None
if tool_name != "AskUserQuestion":
policy, sensitive_pattern = _maybe_override_policy(
@@ -936,6 +952,18 @@ class AgentManager:
if tool_name and tool_name != "AskUserQuestion":
tool_input = input_data.get("tool_input", {})
+ if _is_claude_schedule_skill(tool_name, tool_input):
+ p_note_tool_used(tool_name, False)
+ return {
+ "hookSpecificOutput": {
+ "hookEventName": hook_event,
+ "permissionDecision": "deny",
+ "permissionDecisionReason": (
+ "Use the openswarm-schedule MCP tools instead "
+ "of Claude's internal schedule skill."
+ ),
+ }
+ }
policy, sensitive_pattern = _maybe_override_policy(
_get_effective_policy(tool_name), tool_name, tool_input
)
@@ -1225,17 +1253,35 @@ class AgentManager:
mcp_registry_ctx = self._build_mcp_registry_summary(session.allowed_tools, session.active_mcps)
global_settings = load_settings()
+ # Nudge the agent to surface workflow conversion proactively when
+ # the user's ask looks recurring. The actual conversion is owned
+ # by the UI prompt; chat should not turn this into a cadence Q&A.
schedule_ctx = (
"\n"
- "After completing a substantive task, if the work looks "
- "repeatable (the user said 'every', 'each', 'daily', "
- "'weekly', 'morning', 'before standup', or you just did "
- "the same sequence twice in this session), offer to "
- "schedule it. Use AskUserQuestion to confirm cadence, "
- "then ScheduleWorkflow to create it. Never reach for "
- "crontab, launchctl, or schtasks; always use the native "
- "scheduler so the user can see, pause, and edit it. "
- "Don't ask after trivial one-off requests.\n"
+ "When a normal chat produces work that would naturally be "
+ "useful again later, prefer the openswarm-schedule MCP "
+ "workflow path over Claude's internal scheduler. If the user "
+ "explicitly says 'daily', 'weekly', 'every', 'each', "
+ "'morning', 'before standup', 'monitor', 'alert me', or "
+ "'keep this updated', treat that as a strong recurring-work "
+ "signal. After completing that work, call "
+ "SuggestConvertToWorkflow with a short reason and cadence "
+ "hint so the UI can open its workflow-conversion prompt. You "
+ "may say one short sentence before the tool call, such as "
+ "'This is a good fit for built-in Workflows.' Do not repeat "
+ "that advice after the tool call, do not send a follow-up "
+ "assistant message like 'Done', do not say you are 'nudging' "
+ "the UI, and do not mention the tool. If the user "
+ "then says 'ok', 'yes', or 'do it', do not ask what time and "
+ "do not schedule from chat; the UI prompt/Convert button owns "
+ "the conversion flow. Only call ScheduleWorkflow when the user "
+ "explicitly asks you to create a live schedule and has already "
+ "given an exact cadence and time (for example, 'every weekday "
+ "at 8am'). If cadence or time is missing, use "
+ "SuggestConvertToWorkflow instead of asking a follow-up. Never "
+ "use Skill('schedule'), CronCreate, CronList, CronDelete, "
+ "crontab, launchctl, or schtasks for user-facing recurring "
+ "work.\n"
""
)
composed_prompt = self._compose_system_prompt(
@@ -1551,6 +1597,15 @@ class AgentManager:
else:
effective_allowed.append(f"mcp__{name}__*")
+ if "openswarm-schedule" in mcp_servers:
+ effective_allowed = [
+ t for t in effective_allowed
+ if t not in _CLAUDE_INTERNAL_SCHEDULER_TOOLS
+ ]
+ for _bt in _CLAUDE_INTERNAL_SCHEDULER_TOOLS:
+ if _bt not in effective_disallowed:
+ effective_disallowed.append(_bt)
+
# If the openswarm-web MCP was registered, the CLI's built-in
# WebSearch/WebFetch are guaranteed to fail (no Anthropic
# backend). Suppress them so the model picks our MCP variants
diff --git a/backend/apps/agents/schedule_mcp_server.py b/backend/apps/agents/schedule_mcp_server.py
index a4d20c8f..a9f2228f 100644
--- a/backend/apps/agents/schedule_mcp_server.py
+++ b/backend/apps/agents/schedule_mcp_server.py
@@ -4,9 +4,9 @@
Why this exists: the agent should be able to schedule recurring work on
the user's behalf, but ALWAYS through the native scheduler (visible,
auditable, cost-capped) rather than `crontab`. Each tool is a thin
-wrapper around /api/workflows/*. The descriptions are written to nudge
-the agent toward AskUserQuestion-first behavior (confirm cadence with
-the user before calling ScheduleWorkflow).
+wrapper around /api/workflows/*. The descriptions are written to prefer
+UI-owned workflow conversion for vague recurring asks, and to reserve
+ScheduleWorkflow for exact, user-specified live schedules.
"""
import json
@@ -52,8 +52,13 @@ TOOLS = [
"name": "ScheduleWorkflow",
"description": (
"Create a recurring scheduled workflow for the user. Use this "
- "ONLY after confirming cadence with the user via AskUserQuestion "
- "(do not assume — the user must pick or accept the time). "
+ "ONLY when the user explicitly asks you to create a live schedule "
+ "and has already supplied an exact cadence and time. Do not use "
+ "this after a generic convert-to-workflow suggestion, and do not "
+ "ask follow-up questions like 'what time should it run' from a "
+ "normal chat. If cadence or time is missing, call "
+ "SuggestConvertToWorkflow instead so the UI can open the workflow "
+ "conversion prompt. "
"The workflow runs the listed steps on the schedule and is "
"visible in the user's Workflows hub. Never use crontab, "
"launchctl, or schtasks to schedule recurring work; always use "
@@ -237,6 +242,44 @@ TOOLS = [
"required": ["workflow_id"],
},
},
+ {
+ "name": "SuggestConvertToWorkflow",
+ "description": (
+ "Call this at the end of a response when the completed task is a clear "
+ "candidate for repeatable scheduled work (e.g. a daily report, weekly "
+ "digest, recurring data check, monitoring ping, inbox triage, or status "
+ "briefing). Prefer this native workflow nudge over Claude's internal "
+ "schedule skill or CronCreate/CronList/CronDelete tools. Use it whenever "
+ "the user explicitly mentions daily, weekly, every, each, mornings, "
+ "standup, monitoring, alerts, or keeping something updated, and when you "
+ "have just done a sequence that would naturally be useful again later. Do "
+ "NOT call it for one-off tasks, debugging sessions, creative work, or "
+ "anything where 'repeat it tomorrow' would be odd. It is OK to call this "
+ "more than once per session for distinct workflow candidates, but avoid "
+ "repeated nudges for the same task. This nudges the frontend to highlight "
+ "the 'Convert to Workflow' button and open the workflow-conversion "
+ "prompt. In user-facing text, say at most one short sentence, such "
+ "as: 'This is a good fit for built-in Workflows.' Do not repeat the "
+ "advice after this tool returns, do not say you are nudging the UI, "
+ "and do not ask what time it should run. After this tool returns, "
+ "do not send another assistant message like 'Done'; the UI prompt "
+ "will handle the next step."
+ ),
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "reason": {
+ "type": "string",
+ "description": "A brief, user-friendly explanation of why this task is a good candidate for a recurring workflow (e.g. 'This is a daily report that stays the same'). Shown in the tool bubble.",
+ },
+ "suggested_cadence": {
+ "type": "string",
+ "description": "Optional freeform cadence hint (e.g. 'every weekday morning at 9am' or 'weekly on Monday'). Leave blank if uncertain. The frontend will parse it to prefill the schedule.",
+ },
+ },
+ "required": ["reason"],
+ },
+ },
]
@@ -298,6 +341,7 @@ def handle_schedule_workflow(args: dict) -> dict:
"steps": [{"id": f"s{i+1}", "text": s} for i, s in enumerate(steps_in) if s],
"schedule": schedule,
"source_session_id": args.get("source_session_id") or PARENT_SESSION_ID or None,
+ "dashboard_id": DASHBOARD_ID or None,
}
r = _call("POST", "/create", body)
if "_error" in r:
@@ -507,6 +551,15 @@ def handle_read_test_transcript(args: dict) -> dict:
return _ok(f"Test Agent transcript (status: {status}):\n\n{transcript}")
+def handle_suggest_convert_to_workflow(args: dict) -> dict:
+ reason = (args.get("reason") or "").strip()
+ if not reason:
+ return _err("reason is required.")
+ cadence = (args.get("suggested_cadence") or "").strip()
+ result = json.dumps({"reason": reason, "cadence": cadence})
+ return {"content": [{"type": "text", "text": result}]}
+
+
HANDLERS = {
"ScheduleWorkflow": handle_schedule_workflow,
"ListScheduledWorkflows": handle_list,
@@ -520,6 +573,7 @@ HANDLERS = {
"DeleteWorkflowStep": handle_delete_step,
"TestWorkflow": handle_test_workflow,
"ReadTestTranscript": handle_read_test_transcript,
+ "SuggestConvertToWorkflow": handle_suggest_convert_to_workflow,
}
diff --git a/backend/apps/workflows/workflows.py b/backend/apps/workflows/workflows.py
index 3c2f35d5..517cff95 100644
--- a/backend/apps/workflows/workflows.py
+++ b/backend/apps/workflows/workflows.py
@@ -282,7 +282,16 @@ async def create_workflow(body: WorkflowCreate):
pass
storage.save_workflow(wf)
scheduler.kick()
- return _enriched(wf)
+ enriched = _enriched(wf)
+ try:
+ from backend.apps.agents.core.ws_manager import ws_manager
+ await ws_manager.broadcast_global("workflow:updated", {
+ "workflow_id": wf.id,
+ "workflow": enriched,
+ })
+ except Exception:
+ pass
+ return enriched
async def _generate_workflow_metadata(wf: Workflow) -> tuple[str, str, list[str]]:
diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx
index a449a853..06821c35 100644
--- a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx
+++ b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx
@@ -244,6 +244,7 @@ const DashboardCanvas: React.FC = ({
{/* Tether lines between branched cards */}
;
viewCards: Record;
browserCards: Record;
@@ -64,6 +65,7 @@ interface DashboardCardLayerProps {
}
const DashboardCardLayer: React.FC = ({
+ dashboardId,
cards,
viewCards,
browserCards,
@@ -265,6 +267,7 @@ const DashboardCardLayer: React.FC = ({
))}
{workflowsHub && (
= ({ service, size = 16 }) => {
if (service === 'gmail') {
return (
@@ -259,6 +348,10 @@ const AgentCard: React.FC = ({
const defaultModel = useAppSelector((s) => s.settings.data.default_model);
const defaultMode = useAppSelector((s) => s.settings.data.default_mode);
const [converting, setConverting] = useState(false);
+ const [suggestGlowCycle, setSuggestGlowCycle] = useState(0);
+ const [workflowToast, setWorkflowToast] = useState('');
+ const [dismissedWorkflowPromptKey, setDismissedWorkflowPromptKey] = useState('');
+ const workflowSuggestion = useMemo(() => findWorkflowSuggestion(session), [session]);
// Hide the "Convert to workflow" button when this chat is already
// entangled with a workflow (Image #44 note). Two cases:
// (a) The session is one of a workflow's runner sessions, OR
@@ -302,6 +395,76 @@ const AgentCard: React.FC = ({
!sourceWorkflow.schedule?.enabled &&
(session.status === 'completed' || session.status === 'stopped') &&
session.messages.length >= 2;
+ const hasUserPrompt = useMemo(
+ () => (session.messages || []).some((m) => m.role === 'user' && !m.hidden),
+ [session.messages],
+ );
+ const isConvertBlockedByTurn = session.status !== 'completed' && session.status !== 'stopped';
+ const showConvertToWorkflow =
+ !session.is_welcome_draft &&
+ !isWorkflowRunnerSession &&
+ hasUserPrompt &&
+ (session.messages.length >= 2 || isConvertBlockedByTurn || !!workflowSuggestion);
+ const canConvertToWorkflow = showConvertToWorkflow && !isConvertBlockedByTurn && !converting;
+ const workflowSuggestionKey = workflowSuggestion
+ ? `${session.id}:${workflowSuggestion.reason}:${workflowSuggestion.cadence}`
+ : '';
+ const showWorkflowSuggestionPrompt = Boolean(
+ workflowSuggestion &&
+ canConvertToWorkflow &&
+ workflowSuggestionKey &&
+ dismissedWorkflowPromptKey !== workflowSuggestionKey,
+ );
+ const convertChatToWorkflow = useCallback(() => {
+ if (converting) return;
+ const steps = extractStepsFromSession(session);
+ if (steps.length === 0) {
+ setWorkflowToast('Add a prompt before converting this chat to a workflow.');
+ return;
+ }
+ setConverting(true);
+ const draftId = `draft-${session.id}-${Date.now()}`;
+ dispatch(addWorkflowCard({ workflowId: draftId, sourceSessionId: session.id, expandedSessionIds }));
+ dispatch(setWorkflowCardPosition({ workflowId: draftId, x: cardX, y: cardY }));
+ dispatch(setWorkflowCardSize({ workflowId: draftId, width: cardWidth, height: cardHeight }));
+ dispatch(removeCard(session.id));
+ dispatch(openWorkflowCard({
+ workflowId: draftId,
+ sourceSessionId: session.id,
+ view: 'preview',
+ draft: {
+ title: session.name || 'New workflow',
+ description: '',
+ steps,
+ source_session_id: session.id,
+ use_synced_prompt: true,
+ model: defaultModel || session.model,
+ mode: defaultMode || session.mode,
+ suggested_cadence: workflowSuggestion?.cadence || undefined,
+ } as Partial,
+ }));
+ }, [
+ cardHeight,
+ cardWidth,
+ cardX,
+ cardY,
+ converting,
+ defaultMode,
+ defaultModel,
+ dispatch,
+ expandedSessionIds,
+ isConvertBlockedByTurn,
+ session,
+ workflowSuggestion?.cadence,
+ ]);
+ const handleConvertToWorkflow = useCallback((e: React.MouseEvent) => {
+ e.stopPropagation();
+ if (isConvertBlockedByTurn) {
+ setWorkflowToast('Cannot convert to a workflow while the agent is responding.');
+ return;
+ }
+ convertChatToWorkflow();
+ }, [convertChatToWorkflow, isConvertBlockedByTurn]);
// Curated picker label with a tidy fallback for unknowns.
const friendlyModelLabel = useMemo(() => {
const value = session.model;
@@ -318,6 +481,43 @@ const AgentCard: React.FC = ({
}, [session.model, modelsByProvider]);
const scrollOverlayRef = useOverlayScrollPassthrough(isSelected);
+ const suggestionPulseRef = useRef('');
+ const readyPulseRef = useRef('');
+ useEffect(() => {
+ if (!workflowSuggestion) return;
+ const key = `${workflowSuggestion.reason}|${workflowSuggestion.cadence}`;
+ if (canConvertToWorkflow) {
+ if (readyPulseRef.current === key) return;
+ readyPulseRef.current = key;
+ } else {
+ if (suggestionPulseRef.current === key) return;
+ suggestionPulseRef.current = key;
+ }
+ setSuggestGlowCycle((n) => n + 1);
+ dispatch(fadeGlowingAgentCard(session.id, 3200));
+ }, [workflowSuggestion, canConvertToWorkflow, dispatch, session.id]);
+
+ // When the agent schedules a workflow from this chat, pop its card open
+ // next to the chat. Baseline the count once on mount so historical
+ // schedules (e.g. after an app reload) don't re-open on their own.
+ const scheduleWorkflowCount = useMemo(() => countScheduleWorkflowCalls(session), [session]);
+ const baselineScheduleCountRef = useRef(null);
+ const autoOpenedWorkflowIdsRef = useRef>(new Set());
+ useEffect(() => {
+ if (baselineScheduleCountRef.current === null) {
+ baselineScheduleCountRef.current = scheduleWorkflowCount;
+ return;
+ }
+ if (scheduleWorkflowCount <= baselineScheduleCountRef.current) return;
+ for (const wf of Object.values(workflowItems || {})) {
+ if (wf.source_session_id !== session.id) continue;
+ if (autoOpenedWorkflowIdsRef.current.has(wf.id)) continue;
+ autoOpenedWorkflowIdsRef.current.add(wf.id);
+ dispatch(addWorkflowCard({ workflowId: wf.id, sourceSessionId: session.id, expandedSessionIds }));
+ dispatch(openWorkflowCard({ workflowId: wf.id, view: 'saved' }));
+ }
+ }, [scheduleWorkflowCount, workflowItems, session.id, dispatch, expandedSessionIds]);
+
const cardBoxRef = useRef(null);
// Ref so ResizeObserver sees latest value without re-attaching when active flips.
const isDashboardActiveRef = useRef(isDashboardActive);
@@ -907,58 +1107,6 @@ const AgentCard: React.FC = ({
)}
- {(session.status === 'completed' || session.status === 'stopped') && session.messages.length >= 2 && !isWorkflowRunnerSession && (
-
- {
- e.stopPropagation();
- if (converting) return;
- const steps = extractStepsFromSession(session);
- if (steps.length === 0) return;
- setConverting(true);
- const draftId = `draft-${session.id}-${Date.now()}`;
- // The chat card becomes a temporary workflow draft in the
- // same slot. Nothing is persisted until the user chooses
- // Save Draft or Schedule Workflow from the draft card.
- dispatch(addWorkflowCard({ workflowId: draftId, sourceSessionId: session.id, expandedSessionIds }));
- dispatch(setWorkflowCardPosition({ workflowId: draftId, x: cardX, y: cardY }));
- dispatch(setWorkflowCardSize({ workflowId: draftId, width: cardWidth, height: cardHeight }));
- dispatch(removeCard(session.id));
- dispatch(openWorkflowCard({
- workflowId: draftId,
- sourceSessionId: session.id,
- view: 'preview',
- draft: {
- title: session.name || 'New workflow',
- description: '',
- steps,
- source_session_id: session.id,
- use_synced_prompt: true,
- model: defaultModel || session.model,
- mode: defaultMode || session.mode,
- } as Partial,
- }));
- }}
- onMouseDown={(e) => e.stopPropagation()}
- sx={{
- display: 'inline-flex', alignItems: 'center', gap: 0.5,
- color: '#fff',
- bgcolor: c.accent.primary,
- border: `1px solid ${c.accent.primary}`,
- fontSize: '0.78rem', fontWeight: 700,
- px: 1.1, py: 0.5,
- borderRadius: `${c.radius.md}px`,
- cursor: converting ? 'wait' : 'pointer',
- opacity: converting ? 0.7 : 1,
- '&:hover': { filter: 'brightness(1.05)' },
- }}
- >
-
- {converting ? 'Converting…' : 'Convert to workflow'}
-
-
- )}
= ({
-
-
- {friendlyModelLabel}
-
-
-
-
- {session.cost_usd > 0 && hasApiKey && (
-
- ${session.cost_usd.toFixed(4)}
+
+
+
+ {friendlyModelLabel}
+
+
+
+ {session.cost_usd > 0 && hasApiKey && (
+
+ ${session.cost_usd.toFixed(4)}
+
+ )}
+
+ {showConvertToWorkflow && (
+
+ e.stopPropagation()}
+ animate={suggestGlowCycle > 0 ? {
+ scale: [1, 1.06, 1, 1.045, 1],
+ filter: ['brightness(1)', 'brightness(1.18)', 'brightness(1)', 'brightness(1.12)', 'brightness(1)'],
+ boxShadow: [
+ `0 0 0 0 ${c.accent.primary}00`,
+ `0 0 0 4px ${c.accent.primary}99, 0 0 20px 6px ${c.accent.primary}66`,
+ `0 0 0 8px ${c.accent.primary}00`,
+ `0 0 0 3px ${c.accent.primary}88, 0 0 16px 4px ${c.accent.primary}55`,
+ canConvertToWorkflow ? c.shadow.sm : 'none',
+ ],
+ } : undefined}
+ transition={{ duration: 2.4, ease: 'easeInOut' }}
+ sx={{
+ display: 'inline-flex',
+ alignItems: 'center',
+ gap: 0.35,
+ color: canConvertToWorkflow ? '#fff' : c.text.tertiary,
+ bgcolor: canConvertToWorkflow ? c.accent.primary : c.bg.secondary,
+ border: `1px solid ${canConvertToWorkflow ? c.accent.primary : c.border.medium}`,
+ fontSize: '0.68rem',
+ lineHeight: 1,
+ fontWeight: 700,
+ px: 0.8,
+ py: 0.35,
+ minHeight: 22,
+ borderRadius: `${c.radius.sm}px`,
+ cursor: canConvertToWorkflow ? (converting ? 'wait' : 'pointer') : 'not-allowed',
+ opacity: converting ? 0.7 : 1,
+ whiteSpace: 'nowrap',
+ flexShrink: 0,
+ boxShadow: canConvertToWorkflow ? c.shadow.sm : 'none',
+ '&:hover': canConvertToWorkflow ? { filter: 'brightness(1.05)' } : { bgcolor: c.bg.secondary },
+ }}
+ >
+
+ {converting ? 'Converting...' : 'Convert to workflow'}
+
+
)}
@@ -1204,6 +1407,95 @@ const AgentCard: React.FC = ({
) : null}
>
)}
+
+ {
+ e.stopPropagation();
+ setDismissedWorkflowPromptKey(workflowSuggestionKey);
+ }}
+ sx={{
+ position: 'absolute',
+ inset: 0,
+ zIndex: 30,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ p: 2,
+ bgcolor: 'rgba(0,0,0,0.28)',
+ backdropFilter: 'blur(1.5px)',
+ }}
+ >
+ e.stopPropagation()}
+ sx={{
+ width: '100%',
+ maxWidth: 340,
+ bgcolor: c.bg.surface,
+ color: c.text.primary,
+ border: `1px solid ${c.border.medium}`,
+ borderRadius: `${c.radius.lg}px`,
+ boxShadow: c.shadow.lg,
+ p: 2.25,
+ }}
+ >
+
+ Would you like to make this a workflow?
+
+
+ I can open a workflow draft from this chat. You can review the steps and choose the schedule there.
+
+ {workflowSuggestion?.cadence && (
+
+ Suggested cadence: {workflowSuggestion.cadence}
+
+ )}
+
+
+ }
+ onClick={() => {
+ setDismissedWorkflowPromptKey(workflowSuggestionKey);
+ convertChatToWorkflow();
+ }}
+ sx={{
+ textTransform: 'none',
+ bgcolor: c.accent.primary,
+ color: '#fff',
+ fontWeight: 700,
+ boxShadow: c.shadow.sm,
+ '&:hover': { bgcolor: c.accent.hover },
+ }}
+ >
+ Yes, open workflow
+
+
+
+
+
+ setWorkflowToast('')}
+ anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
+ >
+ setWorkflowToast('')}
+ sx={{ fontSize: '0.78rem' }}
+ >
+ {workflowToast}
+
+
);
diff --git a/frontend/src/app/pages/Workflows/SchedulingView.tsx b/frontend/src/app/pages/Workflows/SchedulingView.tsx
index 8a5cccd3..ae84d56e 100644
--- a/frontend/src/app/pages/Workflows/SchedulingView.tsx
+++ b/frontend/src/app/pages/Workflows/SchedulingView.tsx
@@ -66,11 +66,13 @@ export default function SchedulingView({ workflow, steps }: Props) {
(async () => {
try {
const tok = (() => { try { return getAuthToken(); } catch { return ''; } })();
+ const cadenceHint = workflow.suggested_cadence ? ` I think it should run ${workflow.suggested_cadence}.` : '';
+ const prompt = `Greet me in one short sentence, then ask exactly: "When should this workflow run (e.g. every Wednesday at 1pm)?"${cadenceHint}`;
await fetch(`${API_BASE}/agents/sessions/${encodeURIComponent(scheduleSessionId)}/message`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(tok ? { Authorization: `Bearer ${tok}` } : {}) },
body: JSON.stringify({
- prompt: 'Greet me in one short sentence, then ask exactly: "When should this workflow run (e.g. every Wednesday at 1pm)?"',
+ prompt,
hidden: true,
}),
});
diff --git a/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx b/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx
index 853f4043..c3a463ce 100644
--- a/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx
+++ b/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx
@@ -19,7 +19,7 @@ import {
setWorkflowsHubPosition,
setWorkflowsHubSize,
} from '@/shared/state/dashboardLayoutSlice';
-import { openWorkflowCard, createWorkflow, fetchPausedState, setPausedAll, updateWorkflow, deleteWorkflow, runWorkflowNow } from '@/shared/state/workflowsSlice';
+import { openWorkflowCard, createWorkflow, fetchPausedState, fetchWorkflows, setPausedAll, updateWorkflow, deleteWorkflow, runWorkflowNow } from '@/shared/state/workflowsSlice';
import type { Workflow } from '@/shared/state/workflowsSlice';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
@@ -56,6 +56,7 @@ const HANDLE_DEFS: { dir: ResizeDir; sx: Record }[] = [
];
interface Props {
+ dashboardId?: string;
cardX: number;
cardY: number;
cardWidth: number;
@@ -125,6 +126,7 @@ function TimeSavedBadge() {
}
const WorkflowsHubCard: React.FC = ({
+ dashboardId,
cardX, cardY, cardWidth, cardHeight, cardZOrder = 0,
zoom = 1, panX = 0, panY = 0,
isSelected = false, isHighlighted = false, multiDragDelta = null,
@@ -136,7 +138,10 @@ const WorkflowsHubCard: React.FC = ({
const paused = useAppSelector((s) => s.workflows.paused);
const defaultModel = useAppSelector((s) => s.settings.data.default_model);
- useEffect(() => { dispatch(fetchPausedState()); }, [dispatch]);
+ useEffect(() => {
+ dispatch(fetchPausedState());
+ dispatch(fetchWorkflows(dashboardId));
+ }, [dispatch, dashboardId]);
const togglePaused = useCallback(() => {
dispatch(setPausedAll(!paused));
diff --git a/frontend/src/shared/mcpToolMeta.ts b/frontend/src/shared/mcpToolMeta.ts
index 89b6f122..4e5379f3 100644
--- a/frontend/src/shared/mcpToolMeta.ts
+++ b/frontend/src/shared/mcpToolMeta.ts
@@ -121,6 +121,7 @@ export function getWorkflowToolLabel(action: string): string | null {
if (lower === 'testworkflow') return 'Test workflow';
if (lower === 'readtesttranscript') return 'Read test results';
if (lower === 'listworkflows') return 'List workflows';
+ if (lower === 'suggestconverttoworkflow') return 'Suggest workflow';
return null;
}
diff --git a/frontend/src/shared/state/workflowsSlice.ts b/frontend/src/shared/state/workflowsSlice.ts
index 2777b08b..b35bd1bc 100644
--- a/frontend/src/shared/state/workflowsSlice.ts
+++ b/frontend/src/shared/state/workflowsSlice.ts
@@ -101,6 +101,9 @@ export interface Workflow {
* conversion). Compared against the current steps to decide whether to warn
* before scheduling. See scheduleUtils.needsScheduleTestWarning. */
tested_signature?: string | null;
+ /** Suggested cadence from a SuggestConvertToWorkflow tool call (e.g. "every weekday at 9am").
+ * Used to seed the scheduling agent's prompt. Transient draft field only. */
+ suggested_cadence?: string;
}
export interface WorkflowRun {