diff --git a/data/workflows.json b/data/workflows.json
new file mode 100644
index 00000000..6d87952f
--- /dev/null
+++ b/data/workflows.json
@@ -0,0 +1,69 @@
+[
+ {
+ "id": "wf-oncall-triage",
+ "name": "On-call Alert Triage",
+ "description": "Ingest alerts, score severity, and assign to the correct owner.",
+ "enabled": true,
+ "createdAt": "2026-02-01T08:00:00.000Z",
+ "updatedAt": "2026-02-01T08:00:00.000Z",
+ "steps": [
+ { "id": "step-1", "name": "Collect incoming alerts", "description": "Read PagerDuty and Slack alert feeds" },
+ { "id": "step-2", "name": "Classify severity", "description": "Apply severity rules and confidence score" },
+ { "id": "step-3", "name": "Route to service owner", "description": "Assign incident to the owning squad" },
+ { "id": "step-4", "name": "Post timeline update", "description": "Publish initial status update in incident channel" }
+ ]
+ },
+ {
+ "id": "wf-content-ops",
+ "name": "Content Ops Publishing",
+ "description": "Turn approved drafts into scheduled posts across channels.",
+ "enabled": true,
+ "createdAt": "2026-02-02T09:30:00.000Z",
+ "updatedAt": "2026-02-02T09:30:00.000Z",
+ "steps": [
+ { "id": "step-1", "name": "Pull approved drafts", "description": "Fetch approved content from Notion" },
+ { "id": "step-2", "name": "Generate social snippets", "description": "Create channel-specific excerpts" },
+ { "id": "step-3", "name": "Schedule posts", "description": "Queue publication in social scheduler" }
+ ]
+ },
+ {
+ "id": "wf-release-checklist",
+ "name": "Release Checklist Automation",
+ "description": "Automate pre-release quality checks and release notes generation.",
+ "enabled": false,
+ "createdAt": "2026-02-03T12:15:00.000Z",
+ "updatedAt": "2026-02-03T12:15:00.000Z",
+ "steps": [
+ { "id": "step-1", "name": "Run CI test suite", "description": "Execute unit and integration tests" },
+ { "id": "step-2", "name": "Summarize changes", "description": "Aggregate merged PR highlights" },
+ { "id": "step-3", "name": "Draft release notes", "description": "Generate release notes markdown" },
+ { "id": "step-4", "name": "Create release ticket", "description": "Open release ticket with checklist" },
+ { "id": "step-5", "name": "Notify launch channel", "description": "Share release candidate status" }
+ ]
+ },
+ {
+ "id": "wf-customer-escalation",
+ "name": "Customer Escalation Flow",
+ "description": "Detect high-risk tickets and escalate them to response leads.",
+ "enabled": true,
+ "createdAt": "2026-02-04T14:00:00.000Z",
+ "updatedAt": "2026-02-04T14:00:00.000Z",
+ "steps": [
+ { "id": "step-1", "name": "Watch support inbox", "description": "Poll incoming support tickets" },
+ { "id": "step-2", "name": "Identify risk signals", "description": "Flag churn and outage keywords" },
+ { "id": "step-3", "name": "Escalate to response lead", "description": "Assign lead and start SLA timer" }
+ ]
+ },
+ {
+ "id": "wf-data-quality",
+ "name": "Data Quality Guardrail",
+ "description": "Validate critical datasets before they are used for reporting.",
+ "enabled": false,
+ "createdAt": "2026-02-05T10:45:00.000Z",
+ "updatedAt": "2026-02-05T10:45:00.000Z",
+ "steps": [
+ { "id": "step-1", "name": "Load daily extract", "description": "Read warehouse export" },
+ { "id": "step-2", "name": "Run schema checks", "description": "Ensure required fields and types" }
+ ]
+ }
+]
diff --git a/src/app/api/agents/[handle]/route.ts b/src/app/api/agents/[handle]/route.ts
index 526596cb..efd69f8f 100644
--- a/src/app/api/agents/[handle]/route.ts
+++ b/src/app/api/agents/[handle]/route.ts
@@ -51,7 +51,7 @@ export async function GET(
return NextResponse.json(
{
agent: mergedAgent,
- totalActivity: activity.total,
+ totalActivity: activity.items.length,
},
{
headers: {
diff --git a/src/app/api/bounties/[id]/route.ts b/src/app/api/bounties/[id]/route.ts
index b08be1b6..cfbf6e53 100644
--- a/src/app/api/bounties/[id]/route.ts
+++ b/src/app/api/bounties/[id]/route.ts
@@ -39,7 +39,7 @@ function validateTransitionBody(
return {
ok: true,
value: {
- action,
+ action: action as BountyAction,
agentHandle,
...(notes ? { notes } : {}),
},
diff --git a/src/app/api/bounties/route.ts b/src/app/api/bounties/route.ts
index 3aabbb94..8e509ef1 100644
--- a/src/app/api/bounties/route.ts
+++ b/src/app/api/bounties/route.ts
@@ -117,7 +117,7 @@ function validateTransitionBody(
ok: true,
value: {
bountyId,
- action,
+ action: action as BountyAction,
agentHandle,
...(notes ? { notes } : {}),
},
diff --git a/src/app/api/skills/[slug]/route.ts b/src/app/api/skills/[slug]/route.ts
index 34974de0..7389d5c3 100644
--- a/src/app/api/skills/[slug]/route.ts
+++ b/src/app/api/skills/[slug]/route.ts
@@ -45,7 +45,7 @@ export async function GET(
? reviews.reduce((sum, review) => sum + review.rating, 0) / reviewsCount
: 0;
- const allVersions = versionsData as SkillVersionEntry[];
+ const allVersions = versionsData as unknown as SkillVersionEntry[];
const versions = allVersions.find((entry) => entry.slug === slug)?.versions ?? [];
const compatibility =
diff --git a/src/app/api/workflows/[id]/route.ts b/src/app/api/workflows/[id]/route.ts
new file mode 100644
index 00000000..504cb7d4
--- /dev/null
+++ b/src/app/api/workflows/[id]/route.ts
@@ -0,0 +1,98 @@
+import { NextRequest, NextResponse } from "next/server";
+
+import { normalizeSteps, readWorkflows, writeWorkflows } from "@/lib/server/workflowStore";
+
+export async function GET(
+ _request: NextRequest,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ const { id } = await params;
+ const workflows = await readWorkflows();
+ const workflow = workflows.find((item) => item.id === id);
+
+ if (!workflow) {
+ return NextResponse.json({ error: "Workflow not found" }, { status: 404 });
+ }
+
+ return NextResponse.json({ workflow });
+}
+
+export async function PATCH(
+ request: NextRequest,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ const { id } = await params;
+
+ try {
+ const body = await request.json() as {
+ name?: unknown;
+ description?: unknown;
+ steps?: unknown;
+ enabled?: unknown;
+ };
+
+ const workflows = await readWorkflows();
+ const index = workflows.findIndex((item) => item.id === id);
+
+ if (index === -1) {
+ return NextResponse.json({ error: "Workflow not found" }, { status: 404 });
+ }
+
+ const current = workflows[index];
+ const now = new Date().toISOString();
+
+ const next = {
+ ...current,
+ updatedAt: now,
+ };
+
+ if (typeof body.name === "string") {
+ const trimmed = body.name.trim();
+ if (!trimmed) {
+ return NextResponse.json({ error: "name cannot be empty" }, { status: 400 });
+ }
+ next.name = trimmed;
+ }
+
+ if (typeof body.description === "string") {
+ next.description = body.description.trim();
+ }
+
+ if (body.steps !== undefined) {
+ const steps = normalizeSteps(body.steps);
+ if (steps.length === 0) {
+ return NextResponse.json({ error: "steps must be a non-empty array" }, { status: 400 });
+ }
+ next.steps = steps;
+ }
+
+ if (typeof body.enabled === "boolean") {
+ next.enabled = body.enabled;
+ }
+
+ workflows[index] = next;
+ await writeWorkflows(workflows);
+
+ return NextResponse.json({ workflow: next });
+ } catch {
+ return NextResponse.json({ error: "Invalid request body. Expected JSON." }, { status: 400 });
+ }
+}
+
+export async function DELETE(
+ _request: NextRequest,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ const { id } = await params;
+
+ const workflows = await readWorkflows();
+ const nextWorkflows = workflows.filter((item) => item.id !== id);
+
+ if (nextWorkflows.length === workflows.length) {
+ return NextResponse.json({ error: "Workflow not found" }, { status: 404 });
+ }
+
+ await writeWorkflows(nextWorkflows);
+
+ return new NextResponse(null, { status: 204 });
+}
diff --git a/src/app/api/workflows/route.ts b/src/app/api/workflows/route.ts
new file mode 100644
index 00000000..b8339edd
--- /dev/null
+++ b/src/app/api/workflows/route.ts
@@ -0,0 +1,67 @@
+import { NextRequest, NextResponse } from "next/server";
+
+import {
+ normalizeSteps,
+ readWorkflows,
+ writeWorkflows,
+ type WorkflowRecord,
+} from "@/lib/server/workflowStore";
+
+export async function GET(request: NextRequest) {
+ const search = request.nextUrl.searchParams.get("search")?.trim().toLowerCase() || "";
+
+ const workflows = await readWorkflows();
+ const filtered = !search
+ ? workflows
+ : workflows.filter((workflow) => {
+ return (
+ workflow.name.toLowerCase().includes(search)
+ || workflow.description.toLowerCase().includes(search)
+ || workflow.steps.some((step) => step.name.toLowerCase().includes(search))
+ );
+ });
+
+ return NextResponse.json({ workflows: filtered });
+}
+
+export async function POST(request: NextRequest) {
+ try {
+ const body = await request.json() as {
+ name?: unknown;
+ description?: unknown;
+ steps?: unknown;
+ };
+
+ const name = typeof body.name === "string" ? body.name.trim() : "";
+ const description = typeof body.description === "string" ? body.description.trim() : "";
+ const steps = normalizeSteps(body.steps);
+
+ if (!name) {
+ return NextResponse.json({ error: "name is required" }, { status: 400 });
+ }
+
+ if (steps.length === 0) {
+ return NextResponse.json({ error: "steps must be a non-empty array" }, { status: 400 });
+ }
+
+ const workflows = await readWorkflows();
+ const now = new Date().toISOString();
+
+ const workflow: WorkflowRecord = {
+ id: `wf-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
+ name,
+ description,
+ steps,
+ enabled: true,
+ createdAt: now,
+ updatedAt: now,
+ };
+
+ workflows.unshift(workflow);
+ await writeWorkflows(workflows);
+
+ return NextResponse.json({ workflow }, { status: 201 });
+ } catch {
+ return NextResponse.json({ error: "Invalid request body. Expected JSON." }, { status: 400 });
+ }
+}
diff --git a/src/app/collections/[slug]/page.tsx b/src/app/collections/[slug]/page.tsx
index c66c66f3..c009dde8 100644
--- a/src/app/collections/[slug]/page.tsx
+++ b/src/app/collections/[slug]/page.tsx
@@ -139,7 +139,7 @@ export default async function CollectionRoute(props: {
🧰 {s.name}
-
+
by {s.author}
{s.description}
diff --git a/src/app/governance/page.tsx b/src/app/governance/page.tsx
index 4ebbf607..c55779b2 100644
--- a/src/app/governance/page.tsx
+++ b/src/app/governance/page.tsx
@@ -39,7 +39,7 @@ export const metadata: Metadata = {
};
export default function GovernancePage() {
- const data = governanceFramework as FrameworkData;
+ const data = governanceFramework as unknown as FrameworkData;
return (
diff --git a/src/app/mcp/[slug]/mcp-detail-client.tsx b/src/app/mcp/[slug]/mcp-detail-client.tsx
index 30578c94..54af9c0c 100644
--- a/src/app/mcp/[slug]/mcp-detail-client.tsx
+++ b/src/app/mcp/[slug]/mcp-detail-client.tsx
@@ -96,11 +96,13 @@ export function McpDetailClient({ slug, initialServer }: { slug: string; initial
const response = await fetch(`/api/mcp/${encodeURIComponent(slug)}/install`, { method: "POST" });
const json = (await response.json()) as { installs?: number };
- if (typeof json.installs === "number") {
+ const installCount = json.installs;
+
+ if (typeof installCount === "number") {
setPayload((prev) => ({
server: prev?.server ?? activeServer,
health: prev?.health ?? health ?? UNKNOWN_HEALTH,
- installs: json.installs,
+ installs: installCount,
}));
}
diff --git a/src/app/setup/SetupWizardClient.tsx b/src/app/setup/SetupWizardClient.tsx
index 40f0bc04..20851202 100644
--- a/src/app/setup/SetupWizardClient.tsx
+++ b/src/app/setup/SetupWizardClient.tsx
@@ -91,6 +91,10 @@ function HostDetectionStep({
onHostChange: (host: HostId) => void;
}) {
const detectedHint = useMemo(() => {
+ if (typeof window === "undefined" || typeof navigator === "undefined") {
+ return "Auto-detect hint: host detection runs after the page loads.";
+ }
+
const userAgent = navigator.userAgent.toLowerCase();
const platform = navigator.platform.toLowerCase();
@@ -370,7 +374,7 @@ function VerificationStep({ report }: { report: VerificationReport }) {
}
function copyText(text: string) {
- if (navigator?.clipboard?.writeText) {
+ if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
void navigator.clipboard.writeText(text);
}
}
diff --git a/src/app/workflows/[id]/page.tsx b/src/app/workflows/[id]/page.tsx
index 8cab2ea5..d09588cc 100644
--- a/src/app/workflows/[id]/page.tsx
+++ b/src/app/workflows/[id]/page.tsx
@@ -1,330 +1,210 @@
-import { notFound } from "next/navigation";
+/* eslint-disable react/no-unescaped-entities */
+"use client";
+
import Link from "next/link";
-import { getWorkflows, getWorkflowById, getSkills } from "@/lib/data";
+import { useEffect, useMemo, useState } from "react";
+import { useParams, useRouter } from "next/navigation";
+import { ArrowLeft, Loader2, Save, Trash2 } from "lucide-react";
+
import { Badge } from "@/components/ui/badge";
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
-import { Separator } from "@/components/ui/separator";
-import { Breadcrumbs } from "@/components/breadcrumbs";
-import { ArrowRight, Check, Clock, Layers, Zap, ExternalLink } from "lucide-react";
import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Switch } from "@/components/ui/switch";
+import { Textarea } from "@/components/ui/textarea";
+import { parseStepsFromText, stepsToText, type Workflow } from "@/lib/workflows";
+
+export default function WorkflowDetailPage() {
+ const params = useParams<{ id: string }>();
+ const router = useRouter();
+
+ const id = params?.id || "";
+
+ const [workflow, setWorkflow] = useState
(null);
+ const [stepsText, setStepsText] = useState("");
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [deleting, setDeleting] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ const run = async () => {
+ if (!id) return;
+
+ setLoading(true);
+ setError(null);
+
+ try {
+ const response = await fetch(`/api/workflows/${id}`, { cache: "no-store" });
+ if (!response.ok) {
+ throw new Error("Workflow not found");
+ }
+
+ const payload = await response.json() as { workflow: Workflow };
+ setWorkflow(payload.workflow);
+ setStepsText(stepsToText(payload.workflow.steps));
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to load workflow");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ void run();
+ }, [id]);
+
+ const stepCount = useMemo(
+ () => parseStepsFromText(stepsText).length,
+ [stepsText],
+ );
-export function generateStaticParams() {
- return getWorkflows().map((workflow) => ({ id: workflow.id }));
-}
-
-export function generateMetadata({ params }: { params: { id: string } }) {
- const workflow = getWorkflowById(params.id);
- if (!workflow) return { title: "Workflow Not Found" };
-
- return {
- title: `${workflow.name} Workflow — forAgents.dev`,
- description: workflow.description,
- openGraph: {
- title: `${workflow.name} Workflow — forAgents.dev`,
- description: workflow.description,
- url: `https://foragents.dev/workflows/${workflow.id}`,
- siteName: "forAgents.dev",
- type: "website",
- },
- twitter: {
- card: "summary_large_image",
- title: `${workflow.name} Workflow — forAgents.dev`,
- description: workflow.description,
- },
+ const save = async () => {
+ if (!workflow) return;
+
+ setSaving(true);
+ setError(null);
+
+ try {
+ const response = await fetch(`/api/workflows/${id}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ name: workflow.name,
+ description: workflow.description,
+ steps: parseStepsFromText(stepsText),
+ enabled: workflow.enabled,
+ }),
+ });
+
+ const payload = await response.json().catch(() => ({})) as { error?: string; workflow?: Workflow };
+
+ if (!response.ok) {
+ throw new Error(payload.error || "Failed to save workflow");
+ }
+
+ if (payload.workflow) {
+ setWorkflow(payload.workflow);
+ setStepsText(stepsToText(payload.workflow.steps));
+ }
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to save workflow");
+ } finally {
+ setSaving(false);
+ }
};
-}
-
-const difficultyColors = {
- beginner: "bg-green-500/10 text-green-400 border-green-500/20",
- intermediate: "bg-yellow-500/10 text-yellow-400 border-yellow-500/20",
- advanced: "bg-red-500/10 text-red-400 border-red-500/20",
-};
-
-const categoryEmoji: Record = {
- development: "💻",
- marketing: "📢",
- analytics: "📊",
- devops: "🚀",
- security: "🔒",
- hr: "👥",
-};
-
-export default async function WorkflowDetailPage({
- params,
-}: {
- params: Promise<{ id: string }>;
-}) {
- const { id } = await params;
- const workflow = getWorkflowById(id);
- if (!workflow) notFound();
-
- const allSkills = getSkills();
- const skillMap = new Map(allSkills.map((s) => [s.slug, s]));
-
- // Map required skills to actual skill objects
- const requiredSkillObjects = workflow.requiredSkills
- .map((slug) => skillMap.get(slug))
- .filter((s) => s !== undefined);
+ const remove = async () => {
+ setDeleting(true);
+ setError(null);
+
+ try {
+ const response = await fetch(`/api/workflows/${id}`, { method: "DELETE" });
+ if (!response.ok) {
+ throw new Error("Failed to delete workflow");
+ }
+ router.push("/workflows");
+ router.refresh();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to delete workflow");
+ setDeleting(false);
+ }
+ };
- const automatedStepsCount = workflow.steps.filter((s) => s.automated).length;
- const automationPercentage = Math.round(
- (automatedStepsCount / workflow.steps.length) * 100
- );
+ if (loading) {
+ return Loading workflow...
;
+ }
+
+ if (!workflow) {
+ return (
+
+
{error || "Workflow not found."}
+
+ Back to workflows
+
+
+ );
+ }
return (
-
- {/* Breadcrumbs */}
-
-
+
+
+
+
+
+ Back
+
+
+
+
+ {workflow.enabled ? "Enabled" : "Disabled"}
+
- {/* Header */}
-
-
-
-
-
-
{categoryEmoji[workflow.category] || "📦"}
-
-
-
- {workflow.name}
-
-
- {workflow.difficulty}
-
-
-
{workflow.description}
-
- {/* Meta */}
-
-
-
- {workflow.estimatedTime}
-
-
•
-
-
- {workflow.steps.length} steps
-
-
•
-
-
- {automationPercentage}% automated
-
-
•
-
- Category:
-
- {workflow.category}
-
-
-
-
- {/* Tags */}
-
- {workflow.tags.map((tag) => (
-
- {tag}
-
- ))}
-
-
+
+
+ Edit workflow
+ Make changes and save them to the workflow API.
+
+
+
+ Name
+ setWorkflow({ ...workflow, name: e.target.value })}
+ />
-
-
-
-
- {/* Main Content */}
-
-
- {/* Left Column: Steps */}
-
-
-
Workflow Steps
-
- Follow these steps to implement the {workflow.name.toLowerCase()} workflow.
- {automatedStepsCount > 0 && (
- <> {automatedStepsCount} out of {workflow.steps.length} steps can be fully automated.>
- )}
-
-
+
+ Description
+
-
- {workflow.steps.map((step, index) => (
-
-
-
-
- {index + 1}
-
-
-
- {step.name}
- {step.automated && (
-
-
- Auto
-
- )}
-
-
- {step.description}
-
-
-
-
- {step.skills.length > 0 && (
-
-
- Required skills:
-
-
- {step.skills.map((skillSlug) => {
- const skill = skillMap.get(skillSlug);
- return skill ? (
-
-
- {skill.name}
-
-
-
- ) : (
-
- {skillSlug}
-
- );
- })}
-
-
- )}
-
- ))}
-
+
- {/* Flow Visualization */}
-
-
Workflow Flow
-
-
- {workflow.steps.map((step, index) => (
-
-
- {index < workflow.steps.length - 1 && (
-
- )}
-
- ))}
-
-
-
-
+
+ setWorkflow({ ...workflow, enabled: value })}
+ id="enabled"
+ />
+ Enabled
- {/* Right Column: Required Skills */}
-
-
-
- Required Skills
-
- Install these skills to use this workflow
-
-
-
-
- {requiredSkillObjects.length > 0 ? (
- requiredSkillObjects.map((skill) => (
-
-
-
- {skill.name}
-
-
- {skill.description}
-
-
- View skill
-
-
-
-
- ))
- ) : (
-
- {workflow.requiredSkills.map((slug) => (
-
-
- {slug}
-
-
- Skill not yet available
-
-
- ))}
-
- )}
-
-
-
- Browse Skills Directory
-
-
-
+ {error ?
{error}
: null}
+
+
+ void save()} disabled={saving || deleting}>
+ {saving ? : }
+ Save changes
+
+ void remove()} disabled={saving || deleting}>
+ {deleting ? : }
+ Delete workflow
+
-
-
+
+
);
}
diff --git a/src/app/workflows/builder/page.tsx b/src/app/workflows/builder/page.tsx
index 34e1ea4d..46f6ab5d 100644
--- a/src/app/workflows/builder/page.tsx
+++ b/src/app/workflows/builder/page.tsx
@@ -1,511 +1,407 @@
+/* eslint-disable react/no-unescaped-entities */
"use client";
-import { useState } from "react";
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import Link from "next/link";
+import { useEffect, useMemo, useState } from "react";
+import { ArrowDown, ArrowUp, Loader2, Plus, Save, Trash2 } from "lucide-react";
+
+import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
-import { Textarea } from "@/components/ui/textarea";
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
-import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
-import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
-import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
-import {
- ArrowUp,
- ArrowDown,
- Trash2,
- Download,
- Upload,
- Code2,
- Workflow,
- Play,
- Zap,
- GitBranch,
- Filter,
- Activity,
- FileOutput,
-} from "lucide-react";
-import workflowTemplates from "@/data/workflow-templates.json";
-
-// Types
-type StepType = "trigger" | "transform" | "condition" | "action" | "output";
-
-type WorkflowStep = {
- id: string;
- name: string;
- type: StepType;
- description: string;
- config: Record
;
-};
-
-type Workflow = {
- id: string;
- name: string;
- description: string;
- category: string;
- steps: WorkflowStep[];
-};
-
-// Step type configuration
-const stepTypes: Record = {
- trigger: { icon: Zap, color: "text-yellow-400", description: "Initiates the workflow" },
- transform: { icon: GitBranch, color: "text-blue-400", description: "Transforms data" },
- condition: { icon: Filter, color: "text-purple-400", description: "Conditional logic" },
- action: { icon: Activity, color: "text-green-400", description: "Performs an action" },
- output: { icon: FileOutput, color: "text-cyan-400", description: "Outputs results" },
-};
+import { Switch } from "@/components/ui/switch";
+import { Textarea } from "@/components/ui/textarea";
+import { type Workflow, type WorkflowStep } from "@/lib/workflows";
+
+type WorkflowResponse = { workflows: Workflow[] };
+type SingleWorkflowResponse = { workflow: Workflow };
+
+function emptyStep(index: number): WorkflowStep {
+ return {
+ id: `step-${index + 1}`,
+ name: "",
+ description: "",
+ };
+}
+
+function createDraft(): Workflow {
+ const now = new Date().toISOString();
+ return {
+ id: "",
+ name: "",
+ description: "",
+ enabled: true,
+ steps: [emptyStep(0)],
+ createdAt: now,
+ updatedAt: now,
+ };
+}
export default function WorkflowBuilderPage() {
- const [workflow, setWorkflow] = useState(() => ({
- id: `workflow-${crypto.randomUUID()}`,
- name: "New Workflow",
- description: "Describe your workflow",
- category: "Custom",
- steps: [],
- }));
- const [selectedStepIndex, setSelectedStepIndex] = useState(null);
- const [showTemplateDialog, setShowTemplateDialog] = useState(false);
- const [previewTab, setPreviewTab] = useState("yaml");
-
- // Load template
- const loadTemplate = (template: Workflow) => {
- setWorkflow({
- ...template,
- id: `workflow-${crypto.randomUUID()}`,
- });
- setShowTemplateDialog(false);
- setSelectedStepIndex(null);
+ const [initialId, setInitialId] = useState(null);
+ const [allWorkflows, setAllWorkflows] = useState([]);
+ const [selectedId, setSelectedId] = useState("");
+ const [draft, setDraft] = useState(createDraft());
+
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [deleting, setDeleting] = useState(false);
+ const [error, setError] = useState(null);
+ const [success, setSuccess] = useState(null);
+
+ useEffect(() => {
+ if (typeof window === "undefined") return;
+ const value = new URLSearchParams(window.location.search).get("id");
+ setInitialId(value);
+ }, []);
+
+ const loadWorkflows = async () => {
+ const response = await fetch("/api/workflows", { cache: "no-store" });
+ if (!response.ok) {
+ throw new Error("Failed to load workflows");
+ }
+
+ const payload = await response.json() as WorkflowResponse;
+ setAllWorkflows(payload.workflows);
+ return payload.workflows;
};
- // Add new step
- const addStep = (type: StepType) => {
- const newStep: WorkflowStep = {
- id: `step-${crypto.randomUUID()}`,
- name: `New ${type} step`,
- type,
- description: "",
- config: {},
+ const loadWorkflowById = async (id: string) => {
+ const response = await fetch(`/api/workflows/${id}`, { cache: "no-store" });
+ if (!response.ok) {
+ throw new Error("Failed to load selected workflow");
+ }
+
+ const payload = await response.json() as SingleWorkflowResponse;
+ setDraft(payload.workflow);
+ };
+
+ useEffect(() => {
+ const run = async () => {
+ setLoading(true);
+ setError(null);
+
+ try {
+ const workflows = await loadWorkflows();
+ if (initialId) {
+ await loadWorkflowById(initialId);
+ setSelectedId(initialId);
+ } else if (workflows[0]) {
+ setSelectedId(workflows[0].id);
+ await loadWorkflowById(workflows[0].id);
+ }
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to load workflows");
+ } finally {
+ setLoading(false);
+ }
};
- setWorkflow({
- ...workflow,
- steps: [...workflow.steps, newStep],
+
+ void run();
+ }, [initialId]);
+
+ const setStep = (index: number, updates: Partial) => {
+ setDraft((current) => {
+ const steps = [...current.steps];
+ steps[index] = { ...steps[index], ...updates };
+ return { ...current, steps };
});
- setSelectedStepIndex(workflow.steps.length);
};
- // Remove step
+ const addStep = () => {
+ setDraft((current) => ({
+ ...current,
+ steps: [...current.steps, emptyStep(current.steps.length)],
+ }));
+ };
+
const removeStep = (index: number) => {
- const newSteps = workflow.steps.filter((_, i) => i !== index);
- setWorkflow({ ...workflow, steps: newSteps });
- if (selectedStepIndex === index) {
- setSelectedStepIndex(null);
- } else if (selectedStepIndex !== null && selectedStepIndex > index) {
- setSelectedStepIndex(selectedStepIndex - 1);
- }
+ setDraft((current) => {
+ if (current.steps.length <= 1) return current;
+ return {
+ ...current,
+ steps: current.steps.filter((_, i) => i !== index),
+ };
+ });
};
- // Move step
const moveStep = (index: number, direction: "up" | "down") => {
- const newSteps = [...workflow.steps];
- const targetIndex = direction === "up" ? index - 1 : index + 1;
- if (targetIndex < 0 || targetIndex >= newSteps.length) return;
+ setDraft((current) => {
+ const next = [...current.steps];
+ const target = direction === "up" ? index - 1 : index + 1;
- [newSteps[index], newSteps[targetIndex]] = [newSteps[targetIndex], newSteps[index]];
- setWorkflow({ ...workflow, steps: newSteps });
- setSelectedStepIndex(targetIndex);
- };
+ if (target < 0 || target >= next.length) {
+ return current;
+ }
- // Update step
- const updateStep = (index: number, updates: Partial) => {
- const newSteps = [...workflow.steps];
- newSteps[index] = { ...newSteps[index], ...updates };
- setWorkflow({ ...workflow, steps: newSteps });
+ [next[index], next[target]] = [next[target], next[index]];
+ return { ...current, steps: next };
+ });
};
- // Generate YAML
- const generateYAML = () => {
- const yaml = `# ${workflow.name}
-# ${workflow.description}
-
-name: ${workflow.name}
-category: ${workflow.category}
-description: ${workflow.description}
-
-steps:
-${workflow.steps
- .map(
- (step) => ` - id: ${step.id}
- name: ${step.name}
- type: ${step.type}
- description: ${step.description}
- config:
-${Object.entries(step.config)
- .map(([key, value]) => ` ${key}: ${typeof value === "string" ? `"${value}"` : value}`)
- .join("\n")}`,
- )
- .join("\n")}
-`;
- return yaml;
+ const resetForNew = () => {
+ setSelectedId("");
+ setDraft(createDraft());
+ setSuccess(null);
+ setError(null);
};
- // Generate JSON
- const generateJSON = () => {
- return JSON.stringify(workflow, null, 2);
+ const save = async () => {
+ setSaving(true);
+ setError(null);
+ setSuccess(null);
+
+ try {
+ const cleanSteps = draft.steps
+ .map((step, index) => ({
+ id: step.id || `step-${index + 1}`,
+ name: step.name.trim(),
+ description: step.description.trim(),
+ }))
+ .filter((step) => step.name.length > 0);
+
+ if (!draft.name.trim()) {
+ throw new Error("Workflow name is required");
+ }
+
+ if (cleanSteps.length === 0) {
+ throw new Error("Add at least one step before saving");
+ }
+
+ const method = selectedId ? "PATCH" : "POST";
+ const endpoint = selectedId ? `/api/workflows/${selectedId}` : "/api/workflows";
+
+ const response = await fetch(endpoint, {
+ method,
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ name: draft.name,
+ description: draft.description,
+ enabled: draft.enabled,
+ steps: cleanSteps,
+ }),
+ });
+
+ const payload = await response.json().catch(() => ({})) as {
+ error?: string;
+ workflow?: Workflow;
+ };
+
+ if (!response.ok) {
+ throw new Error(payload.error || "Failed to save workflow");
+ }
+
+ if (payload.workflow) {
+ setDraft(payload.workflow);
+ setSelectedId(payload.workflow.id);
+ }
+
+ await loadWorkflows();
+ setSuccess("Workflow saved successfully.");
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to save workflow");
+ } finally {
+ setSaving(false);
+ }
};
- // Export
- const exportWorkflow = (format: "yaml" | "json") => {
- const content = format === "yaml" ? generateYAML() : generateJSON();
- const blob = new Blob([content], { type: "text/plain" });
- const url = URL.createObjectURL(blob);
- const a = document.createElement("a");
- a.href = url;
- a.download = `${workflow.name.toLowerCase().replace(/\s+/g, "-")}.${format}`;
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
- URL.revokeObjectURL(url);
+ const remove = async () => {
+ if (!selectedId) return;
+
+ setDeleting(true);
+ setError(null);
+ setSuccess(null);
+
+ try {
+ const response = await fetch(`/api/workflows/${selectedId}`, { method: "DELETE" });
+ if (!response.ok) {
+ throw new Error("Failed to delete workflow");
+ }
+
+ const remaining = await loadWorkflows();
+ if (remaining[0]) {
+ setSelectedId(remaining[0].id);
+ await loadWorkflowById(remaining[0].id);
+ } else {
+ resetForNew();
+ }
+ setSuccess("Workflow deleted.");
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to delete workflow");
+ } finally {
+ setDeleting(false);
+ }
};
- const selectedStep = selectedStepIndex !== null ? workflow.steps[selectedStepIndex] : null;
+ const stepCount = useMemo(
+ () => draft.steps.filter((step) => step.name.trim().length > 0).length,
+ [draft.steps],
+ );
+
+ if (loading) {
+ return Loading workflow builder...
;
+ }
return (
-
- {/* Header */}
-
-
-
-
-
-
-
{workflow.name}
-
{workflow.description}
-
-
-
-
-
-
-
- Load Template
-
-
-
-
- Workflow Templates
-
- Choose a pre-built workflow template to get started quickly
-
-
-
- {workflowTemplates.map((template) => (
-
loadTemplate(template as Workflow)}
- >
-
-
-
- {template.name}
- {template.description}
-
-
- {template.category}
-
-
-
-
-
-
- {template.steps.length} steps
-
-
-
- ))}
-
-
-
-
exportWorkflow("yaml")}>
-
- Export YAML
-
-
exportWorkflow("json")}>
-
- Export JSON
-
-
-
+
+
+
+
Workflow Builder
+
Build and persist workflows with live CRUD APIs.
+
+
+
+ Back to workflows
+
+ New workflow
- {/* Main Content */}
-
-
- {/* Left Panel - Workflow Steps */}
-
- {/* Workflow Metadata */}
-
-
- Workflow Details
-
-
-
- Name
- setWorkflow({ ...workflow, name: e.target.value })}
- placeholder="Enter workflow name"
- />
-
-
- Description
-
-
- Category
- setWorkflow({ ...workflow, category: e.target.value })}
- placeholder="e.g., Data, DevOps, Marketing"
- />
-
-
-
+
+
+ Select workflow
+ Pick an existing workflow or create a new one.
+
+
+ {
+ const id = e.target.value;
+ setSelectedId(id);
+ if (!id) {
+ resetForNew();
+ return;
+ }
+ void loadWorkflowById(id).catch((err: unknown) => {
+ setError(err instanceof Error ? err.message : "Failed to load workflow");
+ });
+ }}
+ >
+ Create a new workflow
+ {allWorkflows.map((workflow) => (
+
+ {workflow.name}
+
+ ))}
+
- {/* Step Builder */}
-
-
-
-
Workflow Steps
-
- {(Object.keys(stepTypes) as StepType[]).map((type) => {
- const StepIcon = stepTypes[type].icon;
- return (
- addStep(type)}
- className="h-8"
- title={`Add ${type} step`}
- >
-
-
- );
- })}
-
-
- Build your workflow by adding and configuring steps
-
-
- {workflow.steps.length === 0 ? (
-
-
-
No steps yet. Add your first step to begin.
-
- ) : (
-
- {workflow.steps.map((step, index) => {
- const StepIcon = stepTypes[step.type].icon;
- const isSelected = selectedStepIndex === index;
- return (
-
-
setSelectedStepIndex(index)}
- >
-
-
-
-
-
- {index + 1}
-
-
-
-
{step.name}
-
- {step.type}
-
-
- {step.description && (
-
{step.description}
- )}
-
-
-
-
{
- e.stopPropagation();
- moveStep(index, "up");
- }}
- disabled={index === 0}
- className="h-8 w-8 p-0"
- >
-
-
-
{
- e.stopPropagation();
- moveStep(index, "down");
- }}
- disabled={index === workflow.steps.length - 1}
- className="h-8 w-8 p-0"
- >
-
-
-
{
- e.stopPropagation();
- removeStep(index);
- }}
- className="h-8 w-8 p-0 text-red-400 hover:text-red-300"
- >
-
-
-
-
-
-
- {index < workflow.steps.length - 1 && (
-
- )}
-
- );
- })}
-
- )}
-
-
+
+
+ {stepCount} configured steps
+
+ {draft.id ? ID: {draft.id} : Unsaved draft }
+
+
- {/* Right Panel - Step Config & Preview */}
-
- {/* Step Configuration */}
- {selectedStep && selectedStepIndex !== null && (
-
-
-
- {(() => {
- const StepIcon = stepTypes[selectedStep.type].icon;
- return ;
- })()}
- Step Configuration
-
- {stepTypes[selectedStep.type].description}
-
-
-
- Name
- updateStep(selectedStepIndex, { name: e.target.value })}
- placeholder="Step name"
- />
-
-
-
Type
-
updateStep(selectedStepIndex, { type: value })}
+
+
+ Workflow details
+
+
+
+ Name
+ setDraft((current) => ({ ...current, name: e.target.value }))}
+ placeholder="On-call incident response"
+ />
+
+
+ Description
+
+
+ setDraft((current) => ({ ...current, enabled }))}
+ />
+ Enabled
+
+
+
+
+
+
+ Steps
+ Add 2 to 5 steps for best results.
+
+
+ {draft.steps.map((step, index) => (
+
+
+
+
Step {index + 1}
+
+
moveStep(index, "up")} disabled={index === 0}>
+
+
+
moveStep(index, "down")}
+ disabled={index === draft.steps.length - 1}
>
-
-
-
-
- {(Object.keys(stepTypes) as StepType[]).map((type) => (
-
- {type}
-
- ))}
-
-
-
-
- Description
-
-
-
-
-
- )}
-
- {/* Preview Panel */}
-
-
-
-
- Preview
-
-
-
-
- YAML
- JSON
-
-
-
- {generateYAML()}
-
-
-
-
- {generateJSON()}
-
-
-
+ setStep(index, { name: e.target.value })}
+ placeholder="Step name"
+ />
+
-
-
+ ))}
+
+
+
+ Add step
+
+
+
+
+
+
+ {error ?
{error}
: null}
+ {success ?
{success}
: null}
+
+
+ void save()} disabled={saving || deleting}>
+ {saving ? : }
+ Save workflow
+
+ void remove()} disabled={!selectedId || saving || deleting}>
+ {deleting ? : }
+ Delete workflow
+
);
diff --git a/src/app/workflows/page.tsx b/src/app/workflows/page.tsx
index 9e9256d9..08a0a007 100644
--- a/src/app/workflows/page.tsx
+++ b/src/app/workflows/page.tsx
@@ -1,173 +1,285 @@
+/* eslint-disable react/no-unescaped-entities */
+"use client";
+
import Link from "next/link";
-import { getWorkflows } from "@/lib/data";
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { Loader2, Plus, RefreshCcw, Search, Trash2 } from "lucide-react";
+
import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
-import { Clock, Layers, Zap } from "lucide-react";
-
-export const revalidate = 300;
-
-export const metadata = {
- title: "Workflow Templates — forAgents.dev",
- description: "Pre-built agent workflow templates for common automation tasks.",
- openGraph: {
- title: "Workflow Templates — forAgents.dev",
- description: "Pre-built agent workflow templates for common automation tasks.",
- url: "https://foragents.dev/workflows",
- siteName: "forAgents.dev",
- type: "website",
- },
- twitter: {
- card: "summary_large_image",
- title: "Workflow Templates — forAgents.dev",
- description: "Pre-built agent workflow templates for common automation tasks.",
- },
-};
-
-const difficultyColors = {
- beginner: "bg-green-500/10 text-green-400 border-green-500/20",
- intermediate: "bg-yellow-500/10 text-yellow-400 border-yellow-500/20",
- advanced: "bg-red-500/10 text-red-400 border-red-500/20",
-};
-
-const categoryEmoji: Record
= {
- development: "💻",
- marketing: "📢",
- analytics: "📊",
- devops: "🚀",
- security: "🔒",
- hr: "👥",
-};
+import { Textarea } from "@/components/ui/textarea";
+import { parseStepsFromText, type Workflow } from "@/lib/workflows";
+
+async function fetchWorkflows(search: string): Promise {
+ const params = new URLSearchParams();
+ if (search.trim()) {
+ params.set("search", search.trim());
+ }
+
+ const response = await fetch(`/api/workflows?${params.toString()}`, { cache: "no-store" });
+ if (!response.ok) {
+ throw new Error("Failed to load workflows");
+ }
+
+ const payload = await response.json() as { workflows: Workflow[] };
+ return payload.workflows;
+}
export default function WorkflowsPage() {
- const workflows = getWorkflows();
+ const [search, setSearch] = useState("");
+ const [workflows, setWorkflows] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [busyId, setBusyId] = useState(null);
+
+ const [name, setName] = useState("");
+ const [description, setDescription] = useState("");
+ const [stepsText, setStepsText] = useState("Research\nDraft\nReview\nPublish");
+ const [submitting, setSubmitting] = useState(false);
+
+ const load = useCallback(async (term: string) => {
+ setLoading(true);
+ setError(null);
+
+ try {
+ const items = await fetchWorkflows(term);
+ setWorkflows(items);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to load workflows");
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ void load(search);
+ }, 250);
+
+ return () => clearTimeout(timer);
+ }, [load, search]);
+
+ const enabledCount = useMemo(
+ () => workflows.filter((workflow) => workflow.enabled).length,
+ [workflows],
+ );
+
+ const handleCreate = async () => {
+ setSubmitting(true);
+ setError(null);
+
+ try {
+ const response = await fetch("/api/workflows", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ name,
+ description,
+ steps: parseStepsFromText(stepsText),
+ }),
+ });
+
+ if (!response.ok) {
+ const payload = await response.json() as { error?: string };
+ throw new Error(payload.error || "Failed to create workflow");
+ }
+
+ setName("");
+ setDescription("");
+ setStepsText("Research\nDraft\nReview\nPublish");
+ await load(search);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to create workflow");
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const toggleEnabled = async (workflow: Workflow) => {
+ setBusyId(workflow.id);
+ setError(null);
+
+ try {
+ const response = await fetch(`/api/workflows/${workflow.id}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ enabled: !workflow.enabled }),
+ });
- const categories = Array.from(new Set(workflows.map((w) => w.category)));
+ if (!response.ok) {
+ throw new Error("Failed to update workflow");
+ }
+
+ await load(search);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to update workflow");
+ } finally {
+ setBusyId(null);
+ }
+ };
+
+ const removeWorkflow = async (id: string) => {
+ setBusyId(id);
+ setError(null);
+
+ try {
+ const response = await fetch(`/api/workflows/${id}`, { method: "DELETE" });
+ if (!response.ok) {
+ throw new Error("Failed to delete workflow");
+ }
+ await load(search);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to delete workflow");
+ } finally {
+ setBusyId(null);
+ }
+ };
return (
- {/* Hero */}
-
-
-
+
+
+
Workflow Builder
+
+ Create, update, and manage reusable workflows with API-backed persistence.
+
-
-
- 🔗 Workflow Templates
-
-
- Pre-built agent workflow templates for common automation tasks. Connect skills together into powerful automation pipelines.
-
-
-
-
-
{workflows.length} workflows
+
+ {workflows.length} total
+
+ {enabledCount} enabled
+
+
+ {Math.max(workflows.length - enabledCount, 0)} disabled
+
+
+
+
+
+
+
+ Create workflow
+
+ Name, description, and one step per line.
+
+
+
- •
-
-
-
{categories.length} categories
+
+ Steps (one per line)
+
+
+ {submitting ? : null}
+ Save workflow
+
+
+
+
+
+
+
+
+
+ setSearch(e.target.value)}
+ placeholder="Search by name, description, or step"
+ className="pl-9"
+ />
+
void load(search)}>
+
+ Refresh
+
+
+ Open Builder
+
-
-
-
- {/* Categories Overview */}
-
-
- {categories.map((category) => {
- const count = workflows.filter((w) => w.category === category).length;
- return (
-
- {categoryEmoji[category] || "📦"} {category} ({count})
-
- );
- })}
-
-
+ {error ? (
+
+ {error}
+
+ ) : null}
- {/* Workflows List */}
-
-
- {workflows.map((workflow) => (
-
-
+ {loading ? (
+ Loading workflows...
+ ) : workflows.length === 0 ? (
+ No workflows found.
+ ) : (
+
+ {workflows.map((workflow) => (
+
-
-
- {categoryEmoji[workflow.category] || "📦"}
- {workflow.name}
-
+
+ {workflow.name}
- {workflow.difficulty}
+ {workflow.enabled ? "Enabled" : "Disabled"}
-
- {workflow.description}
-
+
{workflow.description || "No description provided."}
-
-
- {/* Meta Info */}
-
-
-
- {workflow.estimatedTime}
-
-
•
-
-
- {workflow.steps.length} steps
-
-
•
-
-
- {workflow.requiredSkills.length} skills
-
-
-
- {/* Tags */}
-
- {workflow.tags.slice(0, 3).map((tag) => (
-
- {tag}
-
- ))}
- {workflow.tags.length > 3 && (
-
- +{workflow.tags.length - 3}
-
- )}
-
-
-
-
- View workflow →
-
-
+
+ {workflow.steps.length} steps
+
+ {workflow.steps.slice(0, 3).map((step) => (
+
• {step.name}
+ ))}
+ {workflow.steps.length > 3 ? (
+
+{workflow.steps.length - 3} more
+ ) : null}
+
+
+
+ Edit
+
+ void toggleEnabled(workflow)}
+ >
+ {workflow.enabled ? "Disable" : "Enable"}
+
+ void removeWorkflow(workflow.id)}
+ >
+
+ Delete
+
-
- ))}
-
+ ))}
+
+ )}
);
diff --git a/src/lib/server/workflowStore.ts b/src/lib/server/workflowStore.ts
new file mode 100644
index 00000000..99c60eab
--- /dev/null
+++ b/src/lib/server/workflowStore.ts
@@ -0,0 +1,101 @@
+import "server-only";
+
+import { promises as fs } from "fs";
+import path from "path";
+
+export type WorkflowStep = {
+ id: string;
+ name: string;
+ description: string;
+};
+
+export type WorkflowRecord = {
+ id: string;
+ name: string;
+ description: string;
+ steps: WorkflowStep[];
+ enabled: boolean;
+ createdAt: string;
+ updatedAt: string;
+};
+
+const WORKFLOWS_PATH = path.join(process.cwd(), "data", "workflows.json");
+
+function normalizeStep(step: unknown, index: number): WorkflowStep {
+ if (typeof step === "string") {
+ return {
+ id: `step-${index + 1}`,
+ name: step.trim() || `Step ${index + 1}`,
+ description: "",
+ };
+ }
+
+ if (!step || typeof step !== "object") {
+ return {
+ id: `step-${index + 1}`,
+ name: `Step ${index + 1}`,
+ description: "",
+ };
+ }
+
+ const maybeStep = step as Record;
+ const name = typeof maybeStep.name === "string" ? maybeStep.name.trim() : "";
+ const description = typeof maybeStep.description === "string" ? maybeStep.description.trim() : "";
+ const id = typeof maybeStep.id === "string" && maybeStep.id.trim().length > 0
+ ? maybeStep.id.trim()
+ : `step-${index + 1}`;
+
+ return {
+ id,
+ name: name || `Step ${index + 1}`,
+ description,
+ };
+}
+
+export function normalizeSteps(steps: unknown): WorkflowStep[] {
+ if (!Array.isArray(steps)) return [];
+ return steps.map((step, index) => normalizeStep(step, index));
+}
+
+function normalizeWorkflow(item: unknown): WorkflowRecord | null {
+ if (!item || typeof item !== "object") return null;
+
+ const maybeWorkflow = item as Record;
+ const id = typeof maybeWorkflow.id === "string" ? maybeWorkflow.id.trim() : "";
+ const name = typeof maybeWorkflow.name === "string" ? maybeWorkflow.name.trim() : "";
+ const description = typeof maybeWorkflow.description === "string" ? maybeWorkflow.description.trim() : "";
+ const enabled = typeof maybeWorkflow.enabled === "boolean" ? maybeWorkflow.enabled : true;
+
+ if (!id || !name) return null;
+
+ const now = new Date().toISOString();
+
+ return {
+ id,
+ name,
+ description,
+ enabled,
+ steps: normalizeSteps(maybeWorkflow.steps),
+ createdAt: typeof maybeWorkflow.createdAt === "string" ? maybeWorkflow.createdAt : now,
+ updatedAt: typeof maybeWorkflow.updatedAt === "string" ? maybeWorkflow.updatedAt : now,
+ };
+}
+
+export async function readWorkflows(): Promise {
+ try {
+ const raw = await fs.readFile(WORKFLOWS_PATH, "utf-8");
+ const parsed = JSON.parse(raw) as unknown;
+ if (!Array.isArray(parsed)) return [];
+
+ return parsed
+ .map((item) => normalizeWorkflow(item))
+ .filter((item): item is WorkflowRecord => item !== null);
+ } catch {
+ return [];
+ }
+}
+
+export async function writeWorkflows(workflows: WorkflowRecord[]): Promise {
+ await fs.mkdir(path.dirname(WORKFLOWS_PATH), { recursive: true });
+ await fs.writeFile(WORKFLOWS_PATH, `${JSON.stringify(workflows, null, 2)}\n`, "utf-8");
+}
diff --git a/src/lib/workflows.ts b/src/lib/workflows.ts
new file mode 100644
index 00000000..3e5d971b
--- /dev/null
+++ b/src/lib/workflows.ts
@@ -0,0 +1,31 @@
+export type WorkflowStep = {
+ id: string;
+ name: string;
+ description: string;
+};
+
+export type Workflow = {
+ id: string;
+ name: string;
+ description: string;
+ steps: WorkflowStep[];
+ enabled: boolean;
+ createdAt: string;
+ updatedAt: string;
+};
+
+export function parseStepsFromText(text: string): WorkflowStep[] {
+ return text
+ .split("\n")
+ .map((line) => line.trim())
+ .filter(Boolean)
+ .map((name, index) => ({
+ id: `step-${index + 1}`,
+ name,
+ description: "",
+ }));
+}
+
+export function stepsToText(steps: WorkflowStep[]): string {
+ return steps.map((step) => step.name).join("\n");
+}