+
+
Add New Node
-
Agent Nodes
+
+
-
- {NODE_TYPES.map((node) => (
-
- ))}
-
+
-
Global Nodes
+
-
- {GLOBAL_NODE_TYPES.map((node) => (
-
- ))}
+
diff --git a/ui/src/components/flow/nodes/TriggerNode.tsx b/ui/src/components/flow/nodes/TriggerNode.tsx
new file mode 100644
index 0000000..921693d
--- /dev/null
+++ b/ui/src/components/flow/nodes/TriggerNode.tsx
@@ -0,0 +1,234 @@
+import { NodeProps, NodeToolbar, Position } from "@xyflow/react";
+import { Check, Copy, Edit, Trash2Icon, Webhook } from "lucide-react";
+import Link from "next/link";
+import { memo, useEffect, useState } from "react";
+
+import { useWorkflow } from "@/app/workflow/[workflowId]/contexts/WorkflowContext";
+import { FlowNodeData } from "@/components/flow/types";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+
+import { NodeContent } from "./common/NodeContent";
+import { NodeEditDialog } from "./common/NodeEditDialog";
+import { useNodeHandlers } from "./common/useNodeHandlers";
+
+interface TriggerNodeEditFormProps {
+ name: string;
+ setName: (value: string) => void;
+ endpoint: string;
+}
+
+interface TriggerNodeProps extends NodeProps {
+ data: FlowNodeData;
+}
+
+export const TriggerNode = memo(({ data, selected, id }: TriggerNodeProps) => {
+ const { open, setOpen, handleSaveNodeData, handleDeleteNode } = useNodeHandlers({ id });
+ const { saveWorkflow } = useWorkflow();
+
+ // Form state
+ const [name, setName] = useState(data.name || "API Trigger");
+
+ // Generate trigger_path if not present (should be done on node creation)
+ const [triggerPath] = useState(() => data.trigger_path ?? crypto.randomUUID());
+
+ // Get backend URL from environment
+ const backendUrl = process.env.NEXT_PUBLIC_BACKEND_URL || "http://localhost:8000";
+ const endpoint = `${backendUrl}/api/v1/public/agent/${triggerPath}`;
+
+ // Copy state for button feedback
+ const [copied, setCopied] = useState(false);
+
+ const handleCopy = async () => {
+ await navigator.clipboard.writeText(endpoint);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ };
+
+ const handleSave = async () => {
+ handleSaveNodeData({
+ ...data,
+ name,
+ trigger_path: triggerPath,
+ });
+ setOpen(false);
+ // Save the workflow after updating node data
+ setTimeout(async () => {
+ await saveWorkflow();
+ }, 100);
+ };
+
+ // Reset form state when dialog opens
+ const handleOpenChange = (newOpen: boolean) => {
+ if (newOpen) {
+ setName(data.name || "API Trigger");
+ }
+ setOpen(newOpen);
+ };
+
+ // Update form state when data changes (e.g., from undo/redo)
+ useEffect(() => {
+ if (open) {
+ setName(data.name || "API Trigger");
+ }
+ }, [data, open]);
+
+ // Ensure trigger_path is saved on initial render if it was generated
+ useEffect(() => {
+ if (!data.trigger_path && triggerPath) {
+ handleSaveNodeData({
+ ...data,
+ trigger_path: triggerPath,
+ name: data.name || "API Trigger",
+ });
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ return (
+ <>
+
}
+ nodeType="trigger"
+ onDoubleClick={() => setOpen(true)}
+ nodeId={id}
+ >
+
+
API Endpoint:
+
+
+ {endpoint}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {open && (
+
+ )}
+
+ >
+ );
+});
+
+const TriggerNodeEditForm = ({
+ name,
+ setName,
+ endpoint,
+}: TriggerNodeEditFormProps) => {
+ const [copied, setCopied] = useState(false);
+ const [curlCopied, setCurlCopied] = useState(false);
+
+ const handleCopyEndpoint = async () => {
+ await navigator.clipboard.writeText(endpoint);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ };
+
+ const curlExample = `curl -X POST "${endpoint}" \\
+ -H "X-API-Key: YOUR_API_KEY" \\
+ -H "Content-Type: application/json" \\
+ -d '{"phone_number": "+1234567890", "initial_context": {}}'`;
+
+ const handleCopyCurl = async () => {
+ await navigator.clipboard.writeText(curlExample);
+ setCurlCopied(true);
+ setTimeout(() => setCurlCopied(false), 2000);
+ };
+
+ return (
+
+
+
+
+ setName(e.target.value)}
+ />
+
+
+
+
+
+
+
+ {endpoint}
+
+
+
+
+
+
+
+
+
+ {curlExample}
+
+
+
+
+
+ );
+};
+
+TriggerNode.displayName = "TriggerNode";
diff --git a/ui/src/components/flow/nodes/WebhookNode.tsx b/ui/src/components/flow/nodes/WebhookNode.tsx
new file mode 100644
index 0000000..fa20b37
--- /dev/null
+++ b/ui/src/components/flow/nodes/WebhookNode.tsx
@@ -0,0 +1,691 @@
+import { NodeProps, NodeToolbar, Position } from "@xyflow/react";
+import { AlertCircle, Check, Circle, Copy, Edit, Link2, Loader2, PlusIcon, Trash2Icon } from "lucide-react";
+import { memo, useCallback, useEffect, useState } from "react";
+
+import { useWorkflow } from "@/app/workflow/[workflowId]/contexts/WorkflowContext";
+import {
+ createCredentialApiV1CredentialsPost,
+ listCredentialsApiV1CredentialsGet,
+} from "@/client";
+import { CredentialResponse, WebhookCredentialType } from "@/client/types.gen";
+import { FlowNodeData } from "@/components/flow/types";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Switch } from "@/components/ui/switch";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { Textarea } from "@/components/ui/textarea";
+import { useAuth } from "@/lib/auth";
+
+import { NodeContent } from "./common/NodeContent";
+import { NodeEditDialog } from "./common/NodeEditDialog";
+import { useNodeHandlers } from "./common/useNodeHandlers";
+
+interface WebhookNodeProps extends NodeProps {
+ data: FlowNodeData;
+}
+
+type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
+
+interface CustomHeader {
+ key: string;
+ value: string;
+}
+
+export const WebhookNode = memo(({ data, selected, id }: WebhookNodeProps) => {
+ const { open, setOpen, handleSaveNodeData, handleDeleteNode } = useNodeHandlers({ id });
+ const { saveWorkflow } = useWorkflow();
+ const { getAccessToken } = useAuth();
+
+ // Form state
+ const [name, setName] = useState(data.name || "Webhook");
+ const [enabled, setEnabled] = useState(data.enabled ?? true);
+ const [httpMethod, setHttpMethod] = useState
(data.http_method || "POST");
+ const [endpointUrl, setEndpointUrl] = useState(data.endpoint_url || "");
+ const [credentialUuid, setCredentialUuid] = useState(data.credential_uuid || "");
+ const [customHeaders, setCustomHeaders] = useState(
+ data.custom_headers || []
+ );
+ const [payloadTemplate, setPayloadTemplate] = useState(
+ data.payload_template ? JSON.stringify(data.payload_template, null, 2) : "{}"
+ );
+
+ // Credentials state
+ const [credentials, setCredentials] = useState([]);
+ const [credentialsLoading, setCredentialsLoading] = useState(false);
+
+ // Fetch credentials when dialog opens
+ const fetchCredentials = useCallback(async () => {
+ setCredentialsLoading(true);
+ try {
+ const accessToken = await getAccessToken();
+ const response = await listCredentialsApiV1CredentialsGet({
+ headers: { Authorization: `Bearer ${accessToken}` },
+ });
+ if (response.error) {
+ console.error("Failed to fetch credentials:", response.error);
+ setCredentials([]);
+ return;
+ }
+ if (response.data) {
+ setCredentials(response.data);
+ }
+ } catch (error) {
+ console.error("Failed to fetch credentials:", error);
+ setCredentials([]);
+ } finally {
+ setCredentialsLoading(false);
+ }
+ }, [getAccessToken]);
+
+ const handleSave = async () => {
+ let parsedPayload = {};
+ try {
+ parsedPayload = JSON.parse(payloadTemplate);
+ } catch {
+ // Keep empty object if invalid JSON
+ }
+
+ handleSaveNodeData({
+ ...data,
+ name,
+ enabled,
+ http_method: httpMethod,
+ endpoint_url: endpointUrl,
+ credential_uuid: credentialUuid || undefined,
+ custom_headers: customHeaders.filter((h) => h.key && h.value),
+ payload_template: parsedPayload,
+ });
+ setOpen(false);
+ setTimeout(async () => {
+ await saveWorkflow();
+ }, 100);
+ };
+
+ const handleOpenChange = (newOpen: boolean) => {
+ if (newOpen) {
+ setName(data.name || "Webhook");
+ setEnabled(data.enabled ?? true);
+ setHttpMethod(data.http_method || "POST");
+ setEndpointUrl(data.endpoint_url || "");
+ setCredentialUuid(data.credential_uuid || "");
+ setCustomHeaders(data.custom_headers || []);
+ setPayloadTemplate(
+ data.payload_template ? JSON.stringify(data.payload_template, null, 2) : "{}"
+ );
+ // Fetch credentials when dialog opens
+ fetchCredentials();
+ }
+ setOpen(newOpen);
+ };
+
+ useEffect(() => {
+ if (open) {
+ setName(data.name || "Webhook");
+ setEnabled(data.enabled ?? true);
+ setHttpMethod(data.http_method || "POST");
+ setEndpointUrl(data.endpoint_url || "");
+ setCredentialUuid(data.credential_uuid || "");
+ setCustomHeaders(data.custom_headers || []);
+ setPayloadTemplate(
+ data.payload_template ? JSON.stringify(data.payload_template, null, 2) : "{}"
+ );
+ }
+ }, [data, open]);
+
+ const truncateUrl = (url: string, maxLength: number = 30) => {
+ if (!url) return "Not configured";
+ if (url.length <= maxLength) return url;
+ return url.substring(0, maxLength) + "...";
+ };
+
+ return (
+ <>
+ }
+ nodeType="webhook"
+ onDoubleClick={() => handleOpenChange(true)}
+ nodeId={id}
+ >
+
+
+
+ {data.http_method || "POST"}
+
+
+ {truncateUrl(data.endpoint_url || "")}
+
+
+
+
+
+ {data.enabled !== false ? "Enabled" : "Disabled"}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {open && (
+
+ )}
+
+ >
+ );
+});
+
+interface WebhookNodeEditFormProps {
+ name: string;
+ setName: (value: string) => void;
+ enabled: boolean;
+ setEnabled: (value: boolean) => void;
+ httpMethod: HttpMethod;
+ setHttpMethod: (value: HttpMethod) => void;
+ endpointUrl: string;
+ setEndpointUrl: (value: string) => void;
+ credentialUuid: string;
+ setCredentialUuid: (value: string) => void;
+ credentials: CredentialResponse[];
+ credentialsLoading: boolean;
+ onRefreshCredentials: () => Promise;
+ getAccessToken: () => Promise;
+ customHeaders: CustomHeader[];
+ setCustomHeaders: (value: CustomHeader[]) => void;
+ payloadTemplate: string;
+ setPayloadTemplate: (value: string) => void;
+}
+
+const WebhookNodeEditForm = ({
+ name,
+ setName,
+ enabled,
+ setEnabled,
+ httpMethod,
+ setHttpMethod,
+ endpointUrl,
+ setEndpointUrl,
+ credentialUuid,
+ setCredentialUuid,
+ credentials,
+ credentialsLoading,
+ onRefreshCredentials,
+ getAccessToken,
+ customHeaders,
+ setCustomHeaders,
+ payloadTemplate,
+ setPayloadTemplate,
+}: WebhookNodeEditFormProps) => {
+ const [copied, setCopied] = useState(false);
+
+ // Add Credential Dialog state
+ const [isAddCredentialOpen, setIsAddCredentialOpen] = useState(false);
+ const [newCredName, setNewCredName] = useState("");
+ const [newCredDescription, setNewCredDescription] = useState("");
+ const [newCredType, setNewCredType] = useState("bearer_token");
+ const [newCredData, setNewCredData] = useState>({});
+ const [isCreatingCredential, setIsCreatingCredential] = useState(false);
+ const [credentialError, setCredentialError] = useState(null);
+
+ const handleCreateCredential = async () => {
+ if (!newCredName.trim()) return;
+
+ setIsCreatingCredential(true);
+ setCredentialError(null);
+ try {
+ const accessToken = await getAccessToken();
+ const response = await createCredentialApiV1CredentialsPost({
+ headers: { Authorization: `Bearer ${accessToken}` },
+ body: {
+ name: newCredName,
+ description: newCredDescription || undefined,
+ credential_type: newCredType,
+ credential_data: newCredData,
+ },
+ });
+
+ if (response.error) {
+ const errorDetail = (response.error as { detail?: string })?.detail
+ || "Failed to create credential";
+ setCredentialError(errorDetail);
+ return;
+ }
+
+ if (response.data) {
+ // Refresh credentials list
+ await onRefreshCredentials();
+ // Select the newly created credential
+ setCredentialUuid(response.data.uuid);
+ // Close dialog and reset form
+ setIsAddCredentialOpen(false);
+ setNewCredName("");
+ setNewCredDescription("");
+ setNewCredType("bearer_token");
+ setNewCredData({});
+ setCredentialError(null);
+ }
+ } catch (error) {
+ console.error("Failed to create credential:", error);
+ setCredentialError(
+ error instanceof Error ? error.message : "An unexpected error occurred"
+ );
+ } finally {
+ setIsCreatingCredential(false);
+ }
+ };
+
+ const handleAddCredentialDialogChange = (open: boolean) => {
+ setIsAddCredentialOpen(open);
+ if (!open) {
+ // Reset error when closing dialog
+ setCredentialError(null);
+ }
+ };
+
+ const getCredentialDataFields = (type: WebhookCredentialType) => {
+ switch (type) {
+ case "api_key":
+ return [
+ { key: "header_name", label: "Header Name", placeholder: "X-API-Key" },
+ { key: "api_key", label: "API Key", placeholder: "your-api-key", isSecret: true },
+ ];
+ case "bearer_token":
+ return [
+ { key: "token", label: "Token", placeholder: "your-bearer-token", isSecret: true },
+ ];
+ case "basic_auth":
+ return [
+ { key: "username", label: "Username", placeholder: "username" },
+ { key: "password", label: "Password", placeholder: "password", isSecret: true },
+ ];
+ case "custom_header":
+ return [
+ { key: "header_name", label: "Header Name", placeholder: "X-Custom-Header" },
+ { key: "header_value", label: "Header Value", placeholder: "header-value", isSecret: true },
+ ];
+ default:
+ return [];
+ }
+ };
+
+ const handleCopyPayload = async () => {
+ await navigator.clipboard.writeText(payloadTemplate);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ };
+
+ const addHeader = () => {
+ setCustomHeaders([...customHeaders, { key: "", value: "" }]);
+ };
+
+ const updateHeader = (index: number, field: "key" | "value", value: string) => {
+ const newHeaders = [...customHeaders];
+ newHeaders[index] = { ...newHeaders[index], [field]: value };
+ setCustomHeaders(newHeaders);
+ };
+
+ const removeHeader = (index: number) => {
+ setCustomHeaders(customHeaders.filter((_, i) => i !== index));
+ };
+
+ const availableVariables = [
+ { name: "workflow_run_id", description: "Unique ID of the workflow run" },
+ { name: "workflow_run_name", description: "Name of the workflow run" },
+ { name: "workflow_id", description: "ID of the workflow" },
+ { name: "workflow_name", description: "Name of the workflow" },
+ { name: "initial_context.*", description: "Initial context variables" },
+ { name: "gathered_context.*", description: "Extracted variables" },
+ { name: "cost_info.call_duration_seconds", description: "Call duration" },
+ { name: "completed_at", description: "Completion timestamp" },
+ { name: "disposition_code", description: "Final disposition code" },
+ { name: "recording_url", description: "Call recording URL" },
+ { name: "transcript_url", description: "Transcript URL" },
+ ];
+
+ return (
+
+
+ Basic
+ Auth
+ Headers
+ Payload
+
+
+
+
+
+
+ setName(e.target.value)} />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ setEndpointUrl(e.target.value)}
+ placeholder="https://api.example.com/webhook"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {credentials.length === 0 && !credentialsLoading && (
+
+
+ No credentials found. Click the + button to create one.
+
+
+ )}
+
+ {/* Add Credential Dialog */}
+
+
+
+
+
+
+
+
+ {customHeaders.map((header, index) => (
+
+ updateHeader(index, "key", e.target.value)}
+ className="flex-1"
+ />
+ updateHeader(index, "value", e.target.value)}
+ className="flex-1"
+ />
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {availableVariables.map((v) => (
+
+
+ {`{{${v.name}}}`}
+
+ {v.description}
+
+ ))}
+
+
+
+
+ );
+};
+
+WebhookNode.displayName = "WebhookNode";
diff --git a/ui/src/components/flow/nodes/common/NodeContent.tsx b/ui/src/components/flow/nodes/common/NodeContent.tsx
index 45ad58e..934f1b6 100644
--- a/ui/src/components/flow/nodes/common/NodeContent.tsx
+++ b/ui/src/components/flow/nodes/common/NodeContent.tsx
@@ -12,7 +12,7 @@ interface NodeContentProps {
hovered_through_edge?: boolean;
title: string;
icon: ReactNode;
- nodeType?: 'start' | 'agent' | 'end' | 'global';
+ nodeType?: 'start' | 'agent' | 'end' | 'global' | 'trigger' | 'webhook';
hasSourceHandle?: boolean;
hasTargetHandle?: boolean;
children?: ReactNode;
@@ -32,6 +32,10 @@ const getNodeTypeBadge = (nodeType?: string) => {
return { label: 'End Node', className: 'bg-rose-500 text-white' };
case 'global':
return { label: 'Global Node', className: 'bg-amber-500 text-white' };
+ case 'trigger':
+ return { label: 'API Trigger', className: 'bg-purple-500 text-white' };
+ case 'webhook':
+ return { label: 'Webhook', className: 'bg-indigo-500 text-white' };
default:
return { label: 'Node', className: 'bg-zinc-500 text-white' };
}
@@ -50,6 +54,7 @@ export const NodeContent = ({
children,
className = "",
onDoubleClick,
+ nodeId,
}: NodeContentProps) => {
const badge = getNodeTypeBadge(nodeType);
@@ -80,6 +85,11 @@ export const NodeContent = ({
{title}
+ {nodeId && (
+
+ #{nodeId}
+
+ )}
diff --git a/ui/src/components/flow/nodes/index.ts b/ui/src/components/flow/nodes/index.ts
index 3b86c32..51c6ee3 100644
--- a/ui/src/components/flow/nodes/index.ts
+++ b/ui/src/components/flow/nodes/index.ts
@@ -2,3 +2,5 @@ export * from './AgentNode';
export * from './EndCall';
export * from './GlobalNode';
export * from './StartCall';
+export * from './TriggerNode';
+export * from './WebhookNode';
diff --git a/ui/src/components/flow/types.ts b/ui/src/components/flow/types.ts
index fc6ba8d..cf45a5c 100644
--- a/ui/src/components/flow/types.ts
+++ b/ui/src/components/flow/types.ts
@@ -2,7 +2,9 @@ export enum NodeType {
START_CALL = 'startCall',
AGENT_NODE = 'agentNode',
END_CALL = 'endCall',
- GLOBAL_NODE = 'globalNode'
+ GLOBAL_NODE = 'globalNode',
+ TRIGGER = 'trigger',
+ WEBHOOK = 'webhook'
}
export type FlowNodeData = {
@@ -24,6 +26,20 @@ export type FlowNodeData = {
detect_voicemail?: boolean;
delayed_start?: boolean;
delayed_start_duration?: number;
+ // Trigger node specific
+ trigger_path?: string;
+ // Webhook node specific
+ enabled?: boolean;
+ http_method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
+ endpoint_url?: string;
+ credential_uuid?: string;
+ custom_headers?: Array<{ key: string; value: string }>;
+ payload_template?: Record