diff --git a/backend/modules/mcp/catalog.json b/backend/modules/mcp/catalog.json
index 433d90c65..b76ecf4ad 100644
--- a/backend/modules/mcp/catalog.json
+++ b/backend/modules/mcp/catalog.json
@@ -636,9 +636,112 @@
"platform": {"type": "string", "enum": ["windows", "linux", "macos"]},
"agent": {"type": "string", "description": "Target hostname; empty = auto-resolve from alert source"},
"excludedAgents": {"type": "array", "items": {"type": "string"}},
- "params": {"type": "object", "description": "Executor-specific JSON params (shape per executor type)"},
- "onSuccess": {"type": "array", "items": {"type": "string"}, "description": "Child node ids run when this node succeeds"},
- "onError": {"type": "array", "items": {"type": "string"}, "description": "Child node ids run when this node fails"}
+ "params": {
+ "description": "Executor-specific JSON params (shape per executor type)",
+ "oneOf": [
+ {
+ "type": "object",
+ "description":"only usable for http node",
+ "properties": {
+ "method": { "type": "string" },
+ "url": { "type": "string" },
+ "headers": {
+ "type": "object",
+ "properties": {
+ "Content-Type": { "type": "string" },
+ "Accept": { "type": "string" },
+ "Accept-Encoding": { "type": "string" },
+ "Connection": { "type": "string" }
+ },
+ "required": ["Content-Type", "Accept", "Accept-Encoding", "Connection"]
+ },
+ "body": {
+ "type": "object"
+ }
+ },
+ "required": [ "method", "url", "headers"]
+ },
+ {
+ "type": "object",
+ "description":"parameter set only for llm enrichment",
+ "properties": {
+ "prompt": { "type": "string" }
+ },
+ "required": ["prompt"]
+ },
+ {
+ "type": "object",
+ "description":"parameter set only for notification node",
+ "properties": {
+ "message": { "type": "string" },
+ "type": { "type": "string","enum": ["INFO", "WARNING", "ERROR"]}
+ },
+ "required": ["type", "message"]
+ },
+ {
+ "type": "object",
+ "description":"parameter set only for incident node",
+ "properties": {
+ "type": { "const": "incident" },
+ "description": { "type": "string" }
+ },
+ "required": [ "name", "description"]
+ },
+ {
+ "type": "object",
+ "description":"parameter set only for email node",
+ "properties": {
+ "to": { "type": "string" },
+ "cc": { "type": "string" },
+ "subject": { "type": "string" },
+ "body": { "type": "string" }
+ },
+ "required": [ "to", "cc", "subject", "body"]
+ },
+ {
+ "type": "object",
+ "description":"parameter set only for conditional node",
+ "properties": {
+ "conditions": {
+ "type": ["array", "null"],
+ "minItems": 1,
+ "items": {
+ "type": "object",
+ "properties": {
+ "operator": {
+ "type": ["string", "null"],
+ "enum": [
+ "IS",
+ "IS_NOT",
+ "CONTAINS",
+ "NOT_CONTAINS",
+ "EXISTS",
+ "NOT_EXISTS",
+ "START_WITH",
+ "NOT_START_WITH",
+ "ENDS_WITH",
+ "NOT_ENDS_WITH",
+ "IS_ONE_OF",
+ "IS_NOT_ONE_OF"
+ ],
+ "description": "Operator"
+ },
+ "field": {
+ "type": ["string", "null"],
+ "description": "gjson path into context bag — e.g. alert.name, .result"
+ },
+ "value": {
+ "description": "string; string[] for IS_ONE_OF / IS_NOT_ONE_OF; ignored by EXISTS/NOT_EXISTS"
+ }
+ },
+ "required": ["operator", "field"]
+ }
+ }
+ },
+ "required": ["conditions"]
+ }
+ ]
+ }
},
"required": ["kind", "executor"]
},
diff --git a/frontend/src/features/soar/components/FlowEditor.tsx b/frontend/src/features/soar/components/FlowEditor.tsx
index eede88c31..4b94fcff3 100644
--- a/frontend/src/features/soar/components/FlowEditor.tsx
+++ b/frontend/src/features/soar/components/FlowEditor.tsx
@@ -1,11 +1,13 @@
-import { useEffect, useState } from 'react'
+import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
-import { Code2, LayoutList, Loader2, Lock, Pencil, Trash2, X } from 'lucide-react'
+import { Code2, LayoutList, Loader2, Lock, Pencil, Sparkles, Trash2, X } from 'lucide-react'
import { toast } from 'sonner'
import { cn } from '@/shared/lib/utils'
import { Button } from '@/shared/components/ui/button'
import { YamlCodeEditor } from '@/shared/components/YamlCodeEditor'
import { PlatformBroadcastButton, broadcast, BULK_PATHS } from '@/features/platform-broadcast'
+import { useSocAi } from '@/features/soc-ai/SocAiProvider'
+import { useSocAiConfigured } from '@/features/soc-ai/lib/useSocAiConfig'
import { soarFlowsService, SoarHttpError } from '../services/soar-flows.service'
import { flowToForm, formToInput, flowFormToYaml, yamlToFlowForm, type FlowFormState } from '../lib/flow-yaml'
import { clearHttpBodyErrors, firstHttpBodyError, isValidHttpUrl } from '../lib/http-node-validity'
@@ -33,8 +35,19 @@ export function FlowEditor({
const [busy, setBusy] = useState(false)
const [confirmDelete, setConfirmDelete] = useState(false)
const [identityOpen, setIdentityOpen] = useState(false)
+ const dirtyRef = useRef(false)
+ const {
+ setSoarEditTarget,
+ openPanel,
+ soarEditTarget,
+ soarEditVersion,
+ } = useSocAi()
+ const aiConfigured = useSocAiConfigured()
- const set = (k: K, v: FlowFormState[K]) => setForm((f) => ({ ...f, [k]: v }))
+ const set = (k: K, v: FlowFormState[K]) => {
+ dirtyRef.current = true
+ setForm((f) => ({ ...f, [k]: v }))
+ }
useEffect(() => {
clearHttpBodyErrors()
@@ -51,10 +64,23 @@ export function FlowEditor({
toast.error(t('soar.editor.yamlError', { error: r.error }))
return
}
+ dirtyRef.current = true
setForm({ ...r.form, active: form.active })
setMode('visual')
}
+ useEffect(() => {
+ if (creating || !flow || !soarEditTarget || soarEditVersion === 0) return
+ if (dirtyRef.current) return
+ let cancelled = false
+ soarFlowsService.get(flow.relPath).then((f) => {
+ if (cancelled || dirtyRef.current) return
+ setForm(flowToForm(f))
+ if (mode === 'code') setYaml(flowFormToYaml(flowToForm(f)))
+ }).catch(() => {})
+ return () => { cancelled = true }
+ }, [soarEditVersion])
+
const save = async () => {
if (busy) return
let f = form
@@ -202,6 +228,19 @@ export function FlowEditor({
{!creating && {flow?.relPath}
}
+ {!creating && !readOnly && aiConfigured && (
+
+ )}
) : (
@@ -235,7 +281,10 @@ export function FlowEditor({
nodes={form.nodes}
conditions={form.conditions}
readOnly={readOnly}
- onChange={(patch) => setForm((f) => ({ ...f, roots: patch.roots, nodes: patch.nodes }))}
+ onChange={(patch) => {
+ dirtyRef.current = true
+ setForm((f) => ({ ...f, roots: patch.roots, nodes: patch.nodes }))
+ }}
onConditionsChange={(c) => set('conditions', c)}
/>
@@ -247,7 +296,10 @@ export function FlowEditor({
description={form.description}
maxDepth={form.maxDepth}
readOnly={readOnly}
- onChange={(patch) => setForm((f) => ({ ...f, ...patch }))}
+ onChange={(patch) => {
+ dirtyRef.current = true
+ setForm((f) => ({ ...f, ...patch }))
+ }}
onClose={() => setIdentityOpen(false)}
/>
)}
diff --git a/frontend/src/features/soar/components/SoarCreateDialog.tsx b/frontend/src/features/soar/components/SoarCreateDialog.tsx
new file mode 100644
index 000000000..8dd01c46a
--- /dev/null
+++ b/frontend/src/features/soar/components/SoarCreateDialog.tsx
@@ -0,0 +1,114 @@
+import { useEffect, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { Sparkles, X } from 'lucide-react'
+import { cn } from '@/shared/lib/utils'
+import { Button } from '@/shared/components/ui/button'
+import { Input } from '@/shared/components/ui/input'
+import { Textarea } from '@/shared/components/ui/textarea'
+import { useSocAi } from '@/features/soc-ai/SocAiProvider'
+import { useSocAiConfigured } from '@/features/soc-ai/lib/useSocAiConfig'
+import { useBackdropDismiss } from '@/shared/hooks/useBackdropDismiss'
+
+export function SoarCreateDialog({
+ open,
+ onClose,
+ onManual,
+}: {
+ open: boolean
+ onClose: () => void
+ onManual: () => void
+}) {
+ const { t } = useTranslation()
+ const [name, setName] = useState('')
+ const [description, setDescription] = useState('')
+ const [mode, setMode] = useState<'manual' | 'ai'>('manual')
+ const aiConfigured = useSocAiConfigured()
+ const { openPanel, submit: submitToAssistant, setSoarCreateTarget } = useSocAi()
+
+ useEffect(() => {
+ if (open) {
+ setName('')
+ setDescription('')
+ setMode('manual')
+ }
+ }, [open])
+
+ const backdrop = useBackdropDismiss(onClose)
+
+ if (!open) return null
+
+ const valid = name.trim().length > 0 && (mode === 'manual' || description.trim().length > 0)
+
+ const submit = () => {
+ if (!valid) return
+ if (mode === 'ai') {
+ setSoarCreateTarget({ name: name.trim(), description: description.trim() })
+ onClose()
+ openPanel('soar-create')
+ submitToAssistant(t('soar.create.aiOpener', { name: name.trim(), description: description.trim() }), { scope: 'soar-create' })
+ return
+ }
+ onManual()
+ }
+
+ return (
+
+
+
+ {t('soar.create.title')}
+
+
+
+
+
+
+
+
+
+
+
+ setName(e.target.value)} placeholder={t('soar.create.namePlaceholder')} autoFocus />
+
+ {mode === 'ai' && (
+
+
+
+ )}
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/features/soar/pages/FlowsPage.tsx b/frontend/src/features/soar/pages/FlowsPage.tsx
index 8e5887a1a..92df6f3ef 100644
--- a/frontend/src/features/soar/pages/FlowsPage.tsx
+++ b/frontend/src/features/soar/pages/FlowsPage.tsx
@@ -26,6 +26,8 @@ import {
import { soarFlowsService } from "../services/soar-flows.service";
import { soarExecutionsService } from "../services/soar-executions.service";
import { FlowEditor } from "../components/FlowEditor";
+import { SoarCreateDialog } from "../components/SoarCreateDialog";
+import { useSocAi } from "@/features/soc-ai/SocAiProvider";
import { StartFromModal } from "@/shared/components/StartFromModal";
import { copyOfFlow } from "../lib/duplicate";
import type { Flow } from "../types/soar.types";
@@ -77,7 +79,8 @@ export function FlowsPage() {
creating: boolean;
} | null>(null);
const [starting, setStarting] = useState(false);
-
+ const [createOpen, setCreateOpen] = useState(false);
+ const { soarEditVersion } = useSocAi();
useEffect(() => {
const relPath = (location.state as { selectFlowId?: string } | null)
?.selectFlowId;
@@ -155,6 +158,11 @@ export function FlowsPage() {
load();
}, [load]);
+ // A soc-ai run that touched flows (create or edit) settled — refetch the list.
+ useEffect(() => {
+ if (soarEditVersion > 0) load();
+ }, [soarEditVersion, load]);
+
// Aggregate recent executions into per-flow stats (last run / runs / failures).
// Capped at the most recent 500 executions; refreshed alongside the flows list.
useEffect(() => {
@@ -321,12 +329,21 @@ export function FlowsPage() {
options={copyable.map((f) => ({ id: f.relPath, name: f.name }))}
onScratch={() => {
setStarting(false);
- setEditing({ creating: true });
+ setCreateOpen(true);
}}
onCopy={startFromFlow}
onClose={() => setStarting(false)}
/>
)}
+
+
setCreateOpen(false)}
+ onManual={() => {
+ setCreateOpen(false);
+ setEditing({ creating: true });
+ }}
+ />
);
}
diff --git a/frontend/src/features/soar/types/soar.types.ts b/frontend/src/features/soar/types/soar.types.ts
index 017edb6d8..37ecc820a 100644
--- a/frontend/src/features/soar/types/soar.types.ts
+++ b/frontend/src/features/soar/types/soar.types.ts
@@ -166,7 +166,6 @@ export const EXECUTOR_CATALOG: ExecutorMeta[] = [
},
},
{ type: 'llm_enrich', label: 'LLM enrichment', kinds: ['enrichment'], paramsPlaceholder: { prompt: '' } },
- { type: 'llm_action', label: 'LLM action', kinds: ['executor'], paramsPlaceholder: { prompt: '' } },
{ type: 'notify', label: 'Send notification', kinds: ['executor'], paramsPlaceholder: { message: '', type: 'INFO' } },
{ type: 'incident', label: 'Open incident', kinds: ['executor'], paramsPlaceholder: { name: '', description: '' } },
{ type: 'mail', label: 'Send email', kinds: ['executor'], paramsPlaceholder: { to: '', cc: '', subject: '', body: '' } },
diff --git a/frontend/src/features/soc-ai/SocAiProvider.tsx b/frontend/src/features/soc-ai/SocAiProvider.tsx
index 387bbc4bc..3e4bfa3d4 100644
--- a/frontend/src/features/soc-ai/SocAiProvider.tsx
+++ b/frontend/src/features/soc-ai/SocAiProvider.tsx
@@ -28,7 +28,17 @@ export interface SocAiMessage {
// 'panel', 'dashboard-create' and 'dashboard-edit' all render in the floating
// SocAiPanel (see activeScope) — separate threads, same UI. 'home' has its
// own inline transcript (HomeChatTranscript) and never shows in the panel.
-export type SocAiScope = 'panel' | 'home' | 'dashboard-create' | 'dashboard-edit'
+export type SocAiScope = 'panel' | 'home' | 'dashboard-create' | 'dashboard-edit' | 'soar-edit' | 'soar-create'
+
+export interface SoarEditTarget {
+ relPath: string
+ name: string
+}
+
+export interface SoarCreateTarget {
+ name: string
+ description: string
+}
/** Which existing dashboard the 'dashboard-edit' thread is currently scoped to. */
export interface DashboardEditTarget {
@@ -62,10 +72,17 @@ interface SocAiContextValue {
homeMessages: SocAiMessage[]
dashboardCreateMessages: SocAiMessage[]
dashboardEditMessages: SocAiMessage[]
+ soarEditMessages: SocAiMessage[]
+ soarCreateMessages: SocAiMessage[]
dashboardEditTarget: DashboardEditTarget | null
// Called right before opening the panel with scope 'dashboard-edit' so every
// message sent in that thread carries which dashboard is being worked on.
setDashboardEditTarget: (target: DashboardEditTarget | null) => void
+ soarEditTarget: SoarEditTarget | null
+ setSoarEditTarget: (target: SoarEditTarget | null) => void
+ soarCreateTarget: SoarCreateTarget | null
+ setSoarCreateTarget: (target: SoarCreateTarget | null) => void
+ soarEditVersion: number
// The open item being shared with the agent, or null when nothing is open or
// the person removed it from the conversation.
focus: SocAiFocus | null
@@ -114,7 +131,12 @@ export function SocAiProvider({ children }: { children: ReactNode }) {
const [homeMessages, setHomeMessages] = useState([])
const [dashboardCreateMessages, setDashboardCreateMessages] = useState([])
const [dashboardEditMessages, setDashboardEditMessages] = useState([])
+ const [soarEditMessages, setSoarEditMessages] = useState([])
+ const [soarCreateMessages, setSoarCreateMessages] = useState([])
const [dashboardEditTarget, setDashboardEditTarget] = useState(null)
+ const [soarEditTarget, setSoarEditTarget] = useState(null)
+ const [soarCreateTarget, setSoarCreateTarget] = useState(null)
+ const [soarEditVersion, setSoarEditVersion] = useState(0)
const [openItem, setOpenItem] = useState(null)
// The item the person chose to stop sharing. Reset when nothing is open, so
// the next time that item opens it is shared again.
@@ -131,12 +153,16 @@ export function SocAiProvider({ children }: { children: ReactNode }) {
home: setHomeMessages,
'dashboard-create': setDashboardCreateMessages,
'dashboard-edit': setDashboardEditMessages,
+ 'soar-edit': setSoarEditMessages,
+ 'soar-create': setSoarCreateMessages,
}
const messagesByScope: Record = {
panel: messages,
home: homeMessages,
'dashboard-create': dashboardCreateMessages,
'dashboard-edit': dashboardEditMessages,
+ 'soar-edit': soarEditMessages,
+ 'soar-create': soarCreateMessages,
}
const openKey = openItem ? `${openItem.kind}:${openItem.id}` : null
@@ -159,7 +185,22 @@ export function SocAiProvider({ children }: { children: ReactNode }) {
const clear = useCallback((scope: SocAiScope) => {
abortRef.current?.abort()
setters[scope]([])
- }, [])
+ // A cleared dashboard thread has nothing left to be scoped by — drop the edit
+ // target and go back to the general panel so a leftover "Editing: "
+ // title can't outlive its history. Only when NOT on a dashboard page — there
+ // the scope is still live.
+ if ((scope === 'dashboard-edit' || scope === 'dashboard-create') && !location.pathname.startsWith('/dashboards')) {
+ setDashboardEditTarget(null)
+ setActiveScope('panel')
+ }
+ if (scope === 'soar-edit' && !location.pathname.startsWith('/soar')) {
+ setSoarEditTarget(null)
+ setActiveScope('panel')
+ }
+ if (scope === 'soar-create' && !location.pathname.startsWith('/soar')) {
+ setActiveScope('panel')
+ }
+ }, [location.pathname])
const patchMsg = useCallback((scope: SocAiScope, id: number, fn: (m: SocAiMessage) => SocAiMessage) => {
setters[scope]((list) => list.map((m) => (m.id === id ? fn(m) : m)))
@@ -199,7 +240,11 @@ export function SocAiProvider({ children }: { children: ReactNode }) {
const page =
scope === 'dashboard-edit' && dashboardEditTarget
? `Dashboard editor — the user is editing dashboard "${dashboardEditTarget.name}" (dashboard id: ${dashboardEditTarget.id}). Use the dashboards/visualizations tools with this id to add, update, or remove its widgets; check what's already there first (dashboards.get / visualizations.list) before changing it.`
- : scope === 'panel'
+ : scope === 'soar-edit' && soarEditTarget
+ ? `SOAR flow editor — user is editing flow "${soarEditTarget.name}" at ${soarEditTarget.relPath}. Call soar.rule.get first, then soar.rule.update with the FULL rule JSON (Conditions + Nodes map); preserve all unrelated nodes.`
+ : scope === 'soar-create' && soarCreateTarget
+ ? `SOAR flow creation — create a new SOAR flow named "${soarCreateTarget.name}" with soar.rule.create using FULL rule JSON (Conditions + Nodes map). What it should do: ${soarCreateTarget.description}`
+ : scope === 'panel'
? composePage(pageContext(location.pathname), focusRef.current)
: pageContext(location.pathname)
const lang = (i18n.language || 'en').split('-')[0]
@@ -238,6 +283,12 @@ export function SocAiProvider({ children }: { children: ReactNode }) {
void queryClient.invalidateQueries({ queryKey: DASHBOARDS_QUERY_KEYS.all })
void queryClient.invalidateQueries({ queryKey: VISUALIZATIONS_QUERY_KEYS.all })
}
+ if (scope === 'soar-edit' && (ev.kind === 'final' || ev.kind === 'error')) {
+ setSoarEditVersion((v) => v + 1)
+ }
+ if (scope === 'soar-create' && (ev.kind === 'final' || ev.kind === 'error')) {
+ setSoarEditVersion((v) => v + 1)
+ }
},
ac.signal,
).catch((err) => {
@@ -259,7 +310,11 @@ export function SocAiProvider({ children }: { children: ReactNode }) {
homeMessages,
dashboardCreateMessages,
dashboardEditMessages,
+ soarEditMessages,
+ soarCreateMessages,
dashboardEditTarget,
+ soarEditTarget,
+ soarCreateTarget,
queryClient,
],
)
@@ -273,8 +328,15 @@ export function SocAiProvider({ children }: { children: ReactNode }) {
homeMessages,
dashboardCreateMessages,
dashboardEditMessages,
+ soarEditMessages,
+ soarCreateMessages,
dashboardEditTarget,
setDashboardEditTarget,
+ soarEditTarget,
+ setSoarEditTarget,
+ soarCreateTarget,
+ setSoarCreateTarget,
+ soarEditVersion,
focus,
setFocus,
detachFocus,
@@ -293,7 +355,12 @@ export function SocAiProvider({ children }: { children: ReactNode }) {
homeMessages,
dashboardCreateMessages,
dashboardEditMessages,
+ soarEditMessages,
+ soarCreateMessages,
dashboardEditTarget,
+ soarEditTarget,
+ soarCreateTarget,
+ soarEditVersion,
focus,
setFocus,
detachFocus,
diff --git a/frontend/src/features/soc-ai/components/SocAiPanel.tsx b/frontend/src/features/soc-ai/components/SocAiPanel.tsx
index a2313b3a6..4e0c6063d 100644
--- a/frontend/src/features/soc-ai/components/SocAiPanel.tsx
+++ b/frontend/src/features/soc-ai/components/SocAiPanel.tsx
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from "react";
+import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
ArrowUp,
@@ -16,12 +17,14 @@ import { MessageRow } from "./MessageRow";
// Panel-visible scopes only — 'home' has its own inline transcript and never
// shows here, so it needs no title/empty-state copy in this map.
const SCOPE_TITLE_KEY: Record<
- "panel" | "dashboard-create" | "dashboard-edit",
+ "panel" | "dashboard-create" | "dashboard-edit" | "soar-edit" | "soar-create",
string
> = {
panel: "socAi.chat.title",
"dashboard-create": "socAi.chat.dashboardCreateTitle",
"dashboard-edit": "socAi.chat.dashboardEditTitle",
+ "soar-edit": "socAi.chat.soarEditTitle",
+ "soar-create": "socAi.chat.soarCreateTitle",
};
export function SocAiPanel() {
@@ -33,7 +36,10 @@ export function SocAiPanel() {
messages,
dashboardCreateMessages,
dashboardEditMessages,
+ soarEditMessages,
+ soarCreateMessages,
dashboardEditTarget,
+ soarEditTarget,
focus,
detachFocus,
closePanel,
@@ -44,6 +50,7 @@ export function SocAiPanel() {
const [draft, setDraft] = useState("");
const scrollRef = useRef(null);
const taRef = useRef(null);
+ const navigate = useNavigate();
// Auto-grow the input, capped at 7 lines (max-h-[140px]) so long prompts
// scroll internally instead of eating the message area.
@@ -59,7 +66,11 @@ export function SocAiPanel() {
? dashboardCreateMessages
: activeScope === "dashboard-edit"
? dashboardEditMessages
- : messages;
+ : activeScope === "soar-edit"
+ ? soarEditMessages
+ : activeScope === "soar-create"
+ ? soarCreateMessages
+ : messages;
// 'home' never opens this panel (see the comment above), so it has no
// entry here — fall back to the general panel title if it ever does.
const titleKey =
@@ -114,6 +125,20 @@ export function SocAiPanel() {
})}
)}
+ {activeScope === "soar-edit" && soarEditTarget && (
+
+ )}