diff --git a/CHANGELOG.md b/CHANGELOG.md
index 824b2bb..85425fb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,14 @@
# Changelog
+## 0.5.9
+
+- Select existing workspace categories or add a new category in form settings.
+- Export portable form JSON from the list or editor. Import validated files as new, unsaved drafts using the destination workspace’s access defaults. Existing forms, submissions, and connection settings are preserved.
+
+## 0.5.8
+
+- Replace the checkbox default text input with a Checked / Unchecked selector so both boolean defaults can be set reliably.
+
## 0.5.7
- Make catalog Open form controls compact buttons with keyboard focus styling. In dark mode, use a dark iridescent fill that lights up on hover or keyboard focus, with a shine animation on hover.
diff --git a/README.md b/README.md
index 8cad513..b1360d0 100644
--- a/README.md
+++ b/README.md
@@ -14,6 +14,7 @@ ActionStack is an AI-driven project attempting to build something useful for the
- Automation catalog with search, categories, favorites, and pagination.
- Form builder with conditional fields, validation, lookup inputs, multiple-value inputs, and collapsible sections.
- Drafts, publishing, version history, cloning, and recoverable form deletion.
+- Reusable workspace categories and JSON form export/import.
- Configurable SOAR labels, tags, CEF mappings, and approval requirements handled by your playbooks.
- Paginated submission history with playbook/action status counts, delivery retries, and receipts with custom action names, reported block results, summaries, and data.
- Optional receipt timeline and JSON receipt / CSV submission exports for audit review.
@@ -33,6 +34,14 @@ Open ActionStack as a Splunk administrator. The setup wizard creates the first w
See [Deployment](DEPLOYMENT.md) for configuration and permissions, and the [SOAR event contract](SOAR_EVENT_CONTRACT.md) for submitted fields.
+## Moving forms between workspaces or instances
+
+Use **Export** in the form list, or **Export form** in the editor, to download a JSON definition. The editor export includes unsaved edits. Exports include fields, defaults, validation, conditional sections, appearance, category, lookup configuration, and SOAR mapping. They exclude source workspace permissions, form IDs, version history, submissions, and connection credentials.
+
+Choose **Import form**, select the JSON file (one form, up to 200 KB), and choose a destination workspace. ActionStack validates the file and opens a new, unsaved draft with a new ID and the destination workspace's default access. Review SOAR labels and lookup sources before saving or publishing; they must exist on the destination instance. Importing does not overwrite an existing form or create a SOAR event.
+
+In the editor's **Settings** tab, **Category** lists categories used by accessible forms in the current workspace. Choose **Add a new category…** to create another; it becomes reusable when the form is saved.
+
## Development
Requires Node.js 22 and Python 3.9 or 3.13.
diff --git a/frontend/src/CategoryPicker.tsx b/frontend/src/CategoryPicker.tsx
new file mode 100644
index 0000000..bb809e9
--- /dev/null
+++ b/frontend/src/CategoryPicker.tsx
@@ -0,0 +1,57 @@
+import { useState } from "react";
+
+export function CategoryPicker({
+ value,
+ categories,
+ onChange,
+}: {
+ value: string;
+ categories: string[];
+ onChange: (value: string) => void;
+}) {
+ const [adding, setAdding] = useState(false);
+ const choices = Array.from(
+ new Set(
+ ["General", ...categories, value].map((c) => c.trim()).filter(Boolean),
+ ),
+ ).sort((a, b) => a.localeCompare(b));
+ return (
+
+
+ {adding && (
+
+ )}
+
+ );
+}
diff --git a/frontend/src/ImportForm.tsx b/frontend/src/ImportForm.tsx
new file mode 100644
index 0000000..d8cd066
--- /dev/null
+++ b/frontend/src/ImportForm.tsx
@@ -0,0 +1,117 @@
+import { useState } from "react";
+import { api } from "./api";
+import type { Form, Workspace } from "./types";
+import { MAX_FORM_FILE_BYTES, parseFormFile } from "./form-transfer.js";
+
+export function ImportForm({
+ workspaces,
+ workspaceId,
+ onImported,
+ onCancel,
+}: {
+ workspaces: Workspace[];
+ workspaceId: string;
+ onImported: (form: Form) => void;
+ onCancel: () => void;
+}) {
+ const [destination, setDestination] = useState(
+ workspaces.some((w) => w.id === workspaceId)
+ ? workspaceId
+ : workspaces[0]?.id || "",
+ );
+ const [file, setFile] = useState(null);
+ const [error, setError] = useState("");
+ const [busy, setBusy] = useState(false);
+ return (
+
+ );
+}
diff --git a/frontend/src/form-transfer.js b/frontend/src/form-transfer.js
new file mode 100644
index 0000000..161fcb2
--- /dev/null
+++ b/frontend/src/form-transfer.js
@@ -0,0 +1,40 @@
+import { cloneDefinition } from "./browser-compat.js";
+
+export const MAX_FORM_FILE_BYTES = 200 * 1024;
+
+/** Export a portable definition, excluding environment-specific access and history.
+ * @param {import('./types').Form} form @param {string} appVersion */
+export function formExport(form, appVersion) {
+ const { title, description, category, icon, accent, intro, fields, mapping } =
+ form;
+ return {
+ format: "actionstack-form",
+ format_version: 1,
+ app_version: appVersion,
+ form: cloneDefinition({
+ title,
+ description,
+ category,
+ icon,
+ accent,
+ intro,
+ fields,
+ mapping,
+ }),
+ };
+}
+
+/** @param {string} text @returns {unknown} */
+export function parseFormFile(text) {
+ if (new TextEncoder().encode(text).length > MAX_FORM_FILE_BYTES)
+ throw new Error("Choose an ActionStack form JSON file under 200 KB.");
+ let data;
+ try {
+ data = JSON.parse(text);
+ } catch {
+ throw new Error("This file is not valid JSON.");
+ }
+ if (!data || data.format !== "actionstack-form" || data.format_version !== 1)
+ throw new Error("Choose an ActionStack form export (format version 1).");
+ return data;
+}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index d650f23..e29f40b 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -8,6 +8,7 @@ import React, { useEffect, useState, useRef } from "react";
import { createRoot } from "react-dom/client";
import {
Download,
+ Upload,
ArrowUpRight,
ArrowRight,
ArrowLeft,
@@ -67,6 +68,9 @@ import { formIcon } from "./FormIcons";
import { FormAppearance, ThemePicker } from "./Appearance";
import { FormSections } from "./FormSections";
import { cloneForm } from "./clone-form.js";
+import { CategoryPicker } from "./CategoryPicker";
+import { ImportForm } from "./ImportForm";
+import { formExport, MAX_FORM_FILE_BYTES } from "./form-transfer.js";
import { ApprovalRules } from "./ApprovalRules";
import { Disclosure } from "./Disclosure";
import { RunCounts } from "./RunCounts";
@@ -1104,6 +1108,7 @@ function App() {
)}
{page === "builder" && (
void;
}) {
const [automaticId, setAutomaticId] = useState(false);
+ const [importing, setImporting] = useState(false);
const [labels, setLabels] = useState<{ prefix: string; labels: string[] }>({
prefix: "",
labels: [],
@@ -1731,6 +1739,44 @@ function Builder({
"Cloned as a new draft. Review the settings, then save or publish.",
);
}
+ function exportForm(form: Form) {
+ setError("");
+ try {
+ const contents = JSON.stringify(formExport(form, appVersion), null, 2);
+ if (new TextEncoder().encode(contents).length > MAX_FORM_FILE_BYTES)
+ throw new Error("This form exceeds the 200 KB export limit.");
+ downloadAudit(
+ contents,
+ `actionstack-form-${form.id}.json`,
+ "application/json",
+ );
+ } catch (e) {
+ setError((e as Error).message);
+ }
+ }
+ const importDialog = importing && (
+ setImporting(false)}>
+
+ w.state === "active" &&
+ (!w.role_groups ||
+ can(ctx, "admin") ||
+ w.role_groups.admin.some((r) => ctx.roles.includes(r))),
+ )}
+ onCancel={() => setImporting(false)}
+ onImported={(form) => {
+ start(form);
+ setAutomaticId(true);
+ setImporting(false);
+ setNotice(
+ "Imported as a new draft. Review workspace access, SOAR mapping, and lookup sources, then save or publish.",
+ );
+ }}
+ />
+
+ );
function update(patch: Partial