-
+
+
diff --git a/packages/features/ee/workflows/components/WebCallDialog.tsx b/packages/features/ee/workflows/components/WebCallDialog.tsx
index cffb1a51c3..00614fe2f4 100644
--- a/packages/features/ee/workflows/components/WebCallDialog.tsx
+++ b/packages/features/ee/workflows/components/WebCallDialog.tsx
@@ -454,8 +454,7 @@ export function WebCallDialog({
type="button"
onClick={handleStartCall}
loading={createWebCallMutation.isPending || callStatus === "connecting"}
- disabled={!hasCredits || createWebCallMutation.isPending || callStatus === "connecting"}
- >
+ disabled={!hasCredits || createWebCallMutation.isPending || callStatus === "connecting"}>
{callStatus === "connecting" ? t("connecting") : t("start_web_call")}
diff --git a/packages/features/ee/workflows/components/WorkflowDetailsPage.tsx b/packages/features/ee/workflows/components/WorkflowDetailsPage.tsx
index 94cd760dcc..079dc41a42 100644
--- a/packages/features/ee/workflows/components/WorkflowDetailsPage.tsx
+++ b/packages/features/ee/workflows/components/WorkflowDetailsPage.tsx
@@ -9,12 +9,12 @@ import type { WorkflowPermissions } from "@calcom/lib/server/repository/workflow
import type { WorkflowActions } from "@calcom/prisma/enums";
import { WorkflowTemplates } from "@calcom/prisma/enums";
import type { RouterOutputs } from "@calcom/trpc/react";
-import { trpc } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/components/button";
import { FormCard, FormCardBody } from "@calcom/ui/components/card";
import type { MultiSelectCheckboxesOptionType as Option } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
+import { useAgentsData } from "../hooks/useAgentsData";
import { isCalAIAction, isSMSAction } from "../lib/actionHelperFunctions";
import type { FormValues } from "../pages/workflow";
import { AddActionDialog } from "./AddActionDialog";
@@ -58,8 +58,6 @@ export default function WorkflowDetailsPage(props: Props) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [eventTypeId]);
- const steps = form.getValues("steps") || [];
-
const addAction = (
action: WorkflowActions,
sendTo?: string,
@@ -96,19 +94,14 @@ export default function WorkflowDetailsPage(props: Props) {
includeCalendarEvent: false,
verifiedAt: SCANNING_WORKFLOW_STEPS ? null : new Date(),
agentId: null,
+ inboundAgentId: null,
};
steps?.push(step);
form.setValue("steps", steps);
};
- const agentQueriesTrpc = trpc.useQueries((t) =>
- steps.map((step, index) => {
- const watchedAgentId = form.watch(`steps.${index}.agentId`);
- const agentId = step?.agentId ?? watchedAgentId ?? null;
-
- return t.viewer.aiVoiceAgent.get({ id: agentId ?? "" }, { enabled: !!agentId });
- })
- );
+ const { outboundAgentQueries: agentQueriesTrpc, inboundAgentQueries: inboundAgentQueriesTrpc } =
+ useAgentsData(form);
return (
<>
@@ -145,6 +138,8 @@ export default function WorkflowDetailsPage(props: Props) {
{form.getValues("steps")?.map((step, index) => {
const agentData = agentQueriesTrpc[index]?.data;
const isAgentLoading = agentQueriesTrpc[index]?.isPending;
+ const inboundAgentData = inboundAgentQueriesTrpc[index]?.data;
+ const isInboundAgentLoading = inboundAgentQueriesTrpc[index]?.isPending;
return (
@@ -207,6 +202,9 @@ export default function WorkflowDetailsPage(props: Props) {
isDeleteStepDialogOpen={isDeleteStepDialogOpen}
isAgentLoading={isAgentLoading}
agentData={agentData}
+ inboundAgentData={inboundAgentData}
+ isInboundAgentLoading={isInboundAgentLoading}
+ allOptions={allOptions}
/>
diff --git a/packages/features/ee/workflows/components/WorkflowStepContainer.tsx b/packages/features/ee/workflows/components/WorkflowStepContainer.tsx
index 3802e86e44..f90a624164 100644
--- a/packages/features/ee/workflows/components/WorkflowStepContainer.tsx
+++ b/packages/features/ee/workflows/components/WorkflowStepContainer.tsx
@@ -67,10 +67,10 @@ import emailRatingTemplate from "../lib/reminders/templates/emailRatingTemplate"
import emailReminderTemplate from "../lib/reminders/templates/emailReminderTemplate";
import type { FormValues } from "../pages/workflow";
import "../style/styles.css";
-import { AgentConfigurationSheet } from "./AgentConfigurationSheet";
import { TestPhoneCallDialog } from "./TestPhoneCallDialog";
import { TimeTimeUnitInput } from "./TimeTimeUnitInput";
import { WebCallDialog } from "./WebCallDialog";
+import { AgentConfigurationSheet } from "./agent-configuration/AgentConfigurationSheet";
type User = RouterOutputs["viewer"]["me"]["get"];
@@ -92,6 +92,8 @@ type WorkflowStepProps = {
isDeleteStepDialogOpen?: boolean;
agentData?: RetellAgentWithDetails;
isAgentLoading?: boolean;
+ inboundAgentData?: RetellAgentWithDetails;
+ isInboundAgentLoading?: boolean;
};
const getTimeSectionText = (trigger: WorkflowTriggerEvents, t: TFunction) => {
@@ -157,6 +159,8 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
allOptions,
agentData,
isAgentLoading,
+ inboundAgentData,
+ isInboundAgentLoading: _isInboundAgentLoading,
isDeleteStepDialogOpen,
setIsDeleteStepDialogOpen,
onSaveWorkflow,
@@ -171,10 +175,10 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
const { data: userTeams } = trpc.viewer.teams.list.useQuery({}, { enabled: !teamId });
const [agentConfigurationSheet, setAgentConfigurationSheet] = useState<{
open: boolean;
- activeTab?: "prompt" | "phoneNumber";
+ activeTab?: "outgoingCalls" | "phoneNumber" | "incomingCalls";
}>({
open: false,
- activeTab: "prompt",
+ activeTab: "outgoingCalls",
});
const creditsTeamId = userTeams?.find(
@@ -210,12 +214,15 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
});
const stepAgentId = step?.agentId || form.watch(`steps.${step ? step.stepNumber - 1 : 0}.agentId`) || null;
+ const stepInboundAgentId =
+ step?.inboundAgentId || form.watch(`steps.${step ? step.stepNumber - 1 : 0}.inboundAgentId`) || null;
const updateAgentMutation = trpc.viewer.aiVoiceAgent.update.useMutation({
onSuccess: async () => {
showToast(t("agent_updated_successfully"), "success");
- if (stepAgentId) {
- utils.viewer.aiVoiceAgent.get.invalidate({ id: stepAgentId });
+ const currentAgentId = stepAgentId || stepInboundAgentId;
+ if (currentAgentId) {
+ utils.viewer.aiVoiceAgent.get.invalidate({ id: currentAgentId });
}
},
onError: (error: { message: string }) => {
@@ -227,8 +234,9 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
onSuccess: async () => {
showToast(t("phone_number_unsubscribed_successfully"), "success");
setIsUnsubscribeDialogOpen(false);
- if (stepAgentId) {
- utils.viewer.aiVoiceAgent.get.invalidate({ id: stepAgentId });
+ const currentAgentId = stepAgentId || stepInboundAgentId;
+ if (currentAgentId) {
+ utils.viewer.aiVoiceAgent.get.invalidate({ id: currentAgentId });
}
},
onError: (error: { message: string }) => {
@@ -844,7 +852,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
return;
}
if (!props.readOnly) {
- setAgentConfigurationSheet({ open: true, activeTab: "prompt" });
+ setAgentConfigurationSheet({ open: true, activeTab: "outgoingCalls" });
}
}}>
@@ -962,7 +970,9 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
setAgentConfigurationSheet({ open: true, activeTab: "prompt" })}>
+ onClick={() =>
+ setAgentConfigurationSheet({ open: true, activeTab: "outgoingCalls" })
+ }>
{t("edit")}
@@ -1519,11 +1529,12 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
activeTab={agentConfigurationSheet.activeTab}
onOpenChange={(val) => setAgentConfigurationSheet((prev) => ({ ...prev, open: val }))}
agentId={stepAgentId}
+ inboundAgentId={stepInboundAgentId}
agentData={agentData}
+ inboundAgentData={inboundAgentData}
onUpdate={(data) => {
updateAgentMutation.mutate({
- //eslint-disable-next-line @typescript-eslint/no-non-null-assertion
- id: stepAgentId!,
+ id: data.id,
teamId: teamId,
generalPrompt: data.generalPrompt,
beginMessage: data.beginMessage,
@@ -1536,6 +1547,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
workflowId={params?.workflow as string}
workflowStepId={step?.id}
form={form}
+ eventTypeOptions={props.allOptions}
/>
)}
@@ -1543,7 +1555,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
@@ -1553,7 +1565,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
void;
+ agentId?: string | null;
+ inboundAgentId?: string | null;
+ agentData?: RouterOutputs["viewer"]["aiVoiceAgent"]["get"];
+ inboundAgentData?: RouterOutputs["viewer"]["aiVoiceAgent"]["get"];
+ onUpdate: (data: AgentFormValues & { id: string }) => void;
+ readOnly?: boolean;
+ teamId?: number;
+ isOrganization?: boolean;
+ workflowId?: string;
+ workflowStepId?: number;
+ activeTab?: "outgoingCalls" | "phoneNumber" | "incomingCalls";
+ form: UseFormReturn;
+ eventTypeOptions?: Option[];
+};
+
+export function AgentConfigurationSheet({
+ open,
+ activeTab: _activeTab,
+ onOpenChange,
+ agentId,
+ inboundAgentId,
+ agentData,
+ inboundAgentData,
+ readOnly = false,
+ teamId,
+ isOrganization = false,
+ workflowId,
+ workflowStepId,
+ form,
+ eventTypeOptions = [],
+}: AgentConfigurationSheetProps) {
+ const { t } = useLocale();
+ const utils = trpc.useUtils();
+
+ const [activeTab, setActiveTab] = useState<"outgoingCalls" | "phoneNumber" | "incomingCalls">(
+ _activeTab ?? "outgoingCalls"
+ );
+ const [isWebCallDialogOpen, setIsWebCallDialogOpen] = useState(false);
+
+ const activeAgentId = activeTab === "incomingCalls" ? inboundAgentId : agentId;
+
+ const { outboundAgentForm, inboundAgentForm } = useAgentForms({ agentData, inboundAgentData });
+
+ const updateAgentMutation = trpc.viewer.aiVoiceAgent.update.useMutation({
+ onSuccess: async () => {
+ if (activeAgentId) {
+ await utils.viewer.aiVoiceAgent.get.invalidate({ id: activeAgentId });
+ }
+ },
+ onError: (error: { message: string }) => {
+ showToast(error.message, "error");
+ },
+ });
+
+ const handleAgentUpdate = async (data: AgentFormValues) => {
+ if (!agentId) return;
+
+ const updatePayload = {
+ generalPrompt: restorePromptComplexity(data.generalPrompt),
+ beginMessage: data.beginMessage,
+ language: data.language as Language,
+ voiceId: data.voiceId,
+ };
+
+ await updateAgentMutation.mutateAsync({
+ id: agentId,
+ teamId: teamId,
+ ...updatePayload,
+ });
+ };
+
+ const handleInboundAgentUpdate = async (data: AgentFormValues) => {
+ if (!inboundAgentId) return;
+
+ const updatePayload = {
+ generalPrompt: restorePromptComplexity(data.generalPrompt),
+ beginMessage: data.beginMessage,
+ language: data.language as Language,
+ voiceId: data.voiceId,
+ };
+
+ await updateAgentMutation.mutateAsync({
+ id: inboundAgentId,
+ teamId: teamId,
+ ...updatePayload,
+ });
+ };
+
+ return (
+ <>
+
+
+
+ {t("cal_ai_agent_configuration")}
+ {
+ setActiveTab((val || "outgoingCalls") as "outgoingCalls" | "phoneNumber" | "incomingCalls");
+ }}
+ value={activeTab}
+ options={[
+ { value: "outgoingCalls", label: t("outgoing_calls") },
+ { value: "phoneNumber", label: t("phone_number") },
+ { value: "incomingCalls", label: t("incoming_calls") },
+ ]}
+ isFullWidth={true}
+ />
+
+
+ {activeTab === "outgoingCalls" && (
+
+ )}
+
+ {activeTab === "phoneNumber" && (
+
+ )}
+
+ {activeTab === "incomingCalls" && (
+
+ )}
+
+
+
+
+
+
+
+
+
+ {
+ setIsWebCallDialogOpen(true);
+ }}>
+ {t("web_call")}
+
+
+
+
+
+
+
+
+
+
+
+ {activeAgentId && (
+
+ )}
+
+ {/* */}
+ >
+ );
+}
+
+// const ToolsDialog = ({
+// open,
+// onOpenChange,
+// toolDraft,
+// setToolDraft,
+// onSave,
+// isEditing,
+// }: {
+// open: boolean;
+// onOpenChange: (open: boolean) => void;
+// toolDraft: ToolDraft | null;
+// setToolDraft: (draft: ToolDraft) => void;
+// onSave: () => void;
+// isEditing: boolean;
+// }) => {
+// const { t } = useLocale();
+
+// const TOOL_TYPES = [
+// { label: t("Check Availability"), value: "check_availability_cal" },
+// { label: t("Book Appointment"), value: "book_appointment_cal" },
+// { label: t("End Call"), value: "end_call" },
+// ];
+
+// return (
+//
+// );
+// };
diff --git a/packages/features/ee/workflows/components/agent-configuration/components/dialogs/BuyNumberDialog.tsx b/packages/features/ee/workflows/components/agent-configuration/components/dialogs/BuyNumberDialog.tsx
new file mode 100644
index 0000000000..aac2bc3880
--- /dev/null
+++ b/packages/features/ee/workflows/components/agent-configuration/components/dialogs/BuyNumberDialog.tsx
@@ -0,0 +1,134 @@
+import { CAL_AI_PHONE_NUMBER_MONTHLY_PRICE } from "@calcom/lib/constants";
+import { useLocale } from "@calcom/lib/hooks/useLocale";
+import { trpc } from "@calcom/trpc/react";
+import { Alert } from "@calcom/ui/components/alert";
+import { Button } from "@calcom/ui/components/button";
+import { Dialog } from "@calcom/ui/components/dialog";
+import { DialogContent, DialogHeader, DialogFooter as BaseDialogFooter } from "@calcom/ui/components/dialog";
+import { Label } from "@calcom/ui/components/form";
+import { showToast } from "@calcom/ui/components/toast";
+
+interface BuyNumberDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ agentId?: string | null;
+ workflowId?: string;
+ teamId?: number;
+}
+
+export function BuyNumberDialog({ open, onOpenChange, agentId, workflowId, teamId }: BuyNumberDialogProps) {
+ const { t } = useLocale();
+ const utils = trpc.useUtils();
+
+ const buyNumberMutation = trpc.viewer.phoneNumber.buy.useMutation({
+ onSuccess: async (data: { checkoutUrl?: string; message?: string; phoneNumber?: unknown }) => {
+ if (data.checkoutUrl) {
+ window.location.href = data.checkoutUrl;
+ } else if (data.phoneNumber) {
+ showToast(t("phone_number_purchased_successfully"), "success");
+ await utils.viewer.me.get.invalidate();
+ onOpenChange(false);
+ if (agentId) {
+ utils.viewer.aiVoiceAgent.get.invalidate({ id: agentId });
+ }
+ } else {
+ showToast(data.message || t("something_went_wrong"), "error");
+ }
+ },
+ onError: (error: { message: string }) => {
+ showToast(error.message, "error");
+ },
+ });
+
+ return (
+
+ );
+}
diff --git a/packages/features/ee/workflows/components/agent-configuration/components/dialogs/ConfirmationDialogs.tsx b/packages/features/ee/workflows/components/agent-configuration/components/dialogs/ConfirmationDialogs.tsx
new file mode 100644
index 0000000000..455598c6c7
--- /dev/null
+++ b/packages/features/ee/workflows/components/agent-configuration/components/dialogs/ConfirmationDialogs.tsx
@@ -0,0 +1,93 @@
+import { useLocale } from "@calcom/lib/hooks/useLocale";
+import { trpc } from "@calcom/trpc/react";
+import { ConfirmationDialogContent } from "@calcom/ui/components/dialog";
+import { Dialog as UIDialog } from "@calcom/ui/components/dialog";
+import { showToast } from "@calcom/ui/components/toast";
+
+interface ConfirmationDialogsProps {
+ cancellingNumberId: number | null;
+ setCancellingNumberId: (id: number | null) => void;
+ numberToDelete: string | null;
+ setNumberToDelete: (number: string | null) => void;
+ agentId?: string | null;
+}
+
+export function ConfirmationDialogs({
+ cancellingNumberId,
+ setCancellingNumberId,
+ numberToDelete,
+ setNumberToDelete,
+ agentId,
+}: ConfirmationDialogsProps) {
+ const { t } = useLocale();
+ const utils = trpc.useUtils();
+
+ const cancelSubscriptionMutation = trpc.viewer.phoneNumber.cancel.useMutation({
+ onSuccess: async (data: { message?: string }) => {
+ showToast(data.message || t("phone_number_subscription_cancelled_successfully"), "success");
+ setCancellingNumberId(null);
+
+ await utils.viewer.me.get.invalidate();
+ if (agentId) {
+ await utils.viewer.aiVoiceAgent.get.invalidate({ id: agentId });
+ }
+ },
+ onError: (error: { message: string }) => {
+ showToast(error.message, "error");
+ setCancellingNumberId(null);
+ },
+ });
+
+ const deletePhoneNumberMutation = trpc.viewer.phoneNumber.delete.useMutation({
+ onSuccess: async () => {
+ showToast(t("phone_number_deleted_successfully"), "success");
+ setNumberToDelete(null);
+
+ if (agentId) {
+ await utils.viewer.aiVoiceAgent.get.invalidate({ id: agentId });
+ }
+ },
+ onError: (error: { message: string }) => {
+ showToast(error.message, "error");
+ setNumberToDelete(null);
+ },
+ });
+
+ const confirmCancelSubscription = () => {
+ if (cancellingNumberId) {
+ cancelSubscriptionMutation.mutate({ phoneNumberId: cancellingNumberId });
+ }
+ };
+
+ const confirmDeletePhoneNumber = () => {
+ if (numberToDelete) {
+ deletePhoneNumberMutation.mutate({ phoneNumber: numberToDelete });
+ }
+ };
+
+ return (
+ <>
+ setCancellingNumberId(null)}>
+
+ {t("cancel_phone_number_subscription_confirmation")}
+
+
+
+ setNumberToDelete(null)}>
+
+ {t("delete_phone_number_confirmation")}
+
+
+ >
+ );
+}
diff --git a/packages/features/ee/workflows/components/agent-configuration/components/dialogs/ImportNumberDialog.tsx b/packages/features/ee/workflows/components/agent-configuration/components/dialogs/ImportNumberDialog.tsx
new file mode 100644
index 0000000000..84f990bc95
--- /dev/null
+++ b/packages/features/ee/workflows/components/agent-configuration/components/dialogs/ImportNumberDialog.tsx
@@ -0,0 +1,252 @@
+import { Controller } from "react-hook-form";
+import type { UseFormReturn } from "react-hook-form";
+
+import { useLocale } from "@calcom/lib/hooks/useLocale";
+import { trpc } from "@calcom/trpc/react";
+import { Button } from "@calcom/ui/components/button";
+import { Dialog } from "@calcom/ui/components/dialog";
+import { DialogContent, DialogHeader, DialogFooter as BaseDialogFooter } from "@calcom/ui/components/dialog";
+import { Form, Label, Switch, TextField } from "@calcom/ui/components/form";
+import { Icon } from "@calcom/ui/components/icon";
+import { showToast } from "@calcom/ui/components/toast";
+import { Tooltip } from "@calcom/ui/components/tooltip";
+
+import type { PhoneNumberFormValues } from "../../types/schemas";
+
+interface ImportNumberDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ phoneNumberForm: UseFormReturn;
+ showAdvancedFields: boolean;
+ setShowAdvancedFields: (show: boolean) => void;
+ agentId?: string | null;
+ workflowId?: string;
+ teamId?: number;
+}
+
+export function ImportNumberDialog({
+ open,
+ onOpenChange,
+ phoneNumberForm,
+ showAdvancedFields,
+ setShowAdvancedFields,
+ agentId,
+ workflowId,
+ teamId,
+}: ImportNumberDialogProps) {
+ const { t } = useLocale();
+ const utils = trpc.useUtils();
+
+ const importNumberMutation = trpc.viewer.phoneNumber.import.useMutation({
+ onSuccess: async () => {
+ showToast(t("phone_number_imported_successfully"), "success");
+ onOpenChange(false);
+ phoneNumberForm.reset();
+
+ await utils.viewer.me.get.invalidate();
+ if (agentId) {
+ await utils.viewer.aiVoiceAgent.get.invalidate({ id: agentId });
+ }
+ },
+ onError: (error: { message: string }) => {
+ showToast(error.message, "error");
+ },
+ });
+
+ const handleImportPhoneNumber = (values: PhoneNumberFormValues) => {
+ if (!agentId) {
+ showToast(t("agent_required_for_import"), "error");
+ return;
+ }
+ const mutationPayload = {
+ ...values,
+ workflowId: workflowId,
+ agentId: agentId,
+ teamId: teamId,
+ };
+ importNumberMutation.mutate(mutationPayload);
+ };
+
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/packages/features/ee/workflows/components/agent-configuration/components/forms/AgentConfigForm.tsx b/packages/features/ee/workflows/components/agent-configuration/components/forms/AgentConfigForm.tsx
new file mode 100644
index 0000000000..2f9b323754
--- /dev/null
+++ b/packages/features/ee/workflows/components/agent-configuration/components/forms/AgentConfigForm.tsx
@@ -0,0 +1,176 @@
+import { useRef } from "react";
+import type { UseFormReturn } from "react-hook-form";
+
+import { useLocale } from "@calcom/lib/hooks/useLocale";
+import { AddVariablesDropdown } from "@calcom/ui/components/editor";
+import { Input, Label, TextArea } from "@calcom/ui/components/form";
+
+import { DYNAMIC_TEXT_VARIABLES } from "../../../../lib/constants";
+import type { AgentFormValues } from "../../types/schemas";
+import { LanguageSelector } from "./LanguageSelector";
+import { VoiceSelector } from "./VoiceSelector";
+
+interface AgentConfigFormProps {
+ form: UseFormReturn;
+ readOnly?: boolean;
+ selectedVoiceId?: string;
+ onVoiceDialogOpen: () => void;
+}
+
+export function AgentConfigForm({
+ form,
+ readOnly = false,
+ selectedVoiceId,
+ onVoiceDialogOpen,
+}: AgentConfigFormProps) {
+ const { t } = useLocale();
+ const generalPromptRef = useRef(null);
+
+ const addVariableToGeneralPrompt = (variable: string) => {
+ if (generalPromptRef.current) {
+ const currentPrompt = generalPromptRef.current.value || "";
+ const cursorPosition = generalPromptRef.current.selectionStart || currentPrompt.length;
+ const variableName = `{${variable.toUpperCase().replace(/ /g, "_")}}`;
+ const newPrompt = `${currentPrompt.substring(
+ 0,
+ cursorPosition
+ )}${variableName}${currentPrompt.substring(cursorPosition)}`;
+
+ form.setValue("generalPrompt", newPrompt, { shouldDirty: true, shouldTouch: true });
+
+ requestAnimationFrame(() => {
+ if (generalPromptRef.current) {
+ generalPromptRef.current.focus();
+ generalPromptRef.current.setSelectionRange(
+ cursorPosition + variableName.length,
+ cursorPosition + variableName.length
+ );
+ }
+ });
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+
{t("initial_message_description")}
+
+
+
+
+
+
+
{t("general_prompt_description")}
+
+
+ {!readOnly && (
+
+ )}
+
+
+
+ {/* Commented code for future tools implementation */}
+ {/*
+
+
+
{t("Functions")}
+
+ {t(
+ "Enable your agent with capabilities such as calendar bookings, call termination, etc."
+ )}
+
+
+
+
+
+ {toolFields.map((tool, idx) => (
+
+
+
+
+
+
+
{tool.name}
+
+ {tool.type === "check_availability_cal" && t("Check Availability")}
+ {tool.type === "book_appointment_cal" && t("Book Appointment")}
+ {tool.type === "end_call" && t("End Call")}
+
+
+
+
+
+ {tool.name !== "end_call" && (
+
+ )}
+
+
+ ))}
+ {toolFields.length === 0 && (
+
+
+
{t("No functions configured yet")}
+
+ {t("Add functions to enable advanced capabilities")}
+
+
+ )}
+
+
*/}
+
+ );
+}
diff --git a/packages/features/ee/workflows/components/agent-configuration/components/forms/LanguageSelector.tsx b/packages/features/ee/workflows/components/agent-configuration/components/forms/LanguageSelector.tsx
new file mode 100644
index 0000000000..0171903472
--- /dev/null
+++ b/packages/features/ee/workflows/components/agent-configuration/components/forms/LanguageSelector.tsx
@@ -0,0 +1,37 @@
+import { Controller } from "react-hook-form";
+import type { Control } from "react-hook-form";
+
+import { useLocale } from "@calcom/lib/hooks/useLocale";
+import { Label, Select } from "@calcom/ui/components/form";
+
+import type { AgentFormValues } from "../../types/schemas";
+import { LANGUAGE_OPTIONS } from "../../utils/constants";
+
+interface LanguageSelectorProps {
+ control: Control;
+ name: keyof AgentFormValues;
+ disabled?: boolean;
+}
+
+export function LanguageSelector({ control, name, disabled = false }: LanguageSelectorProps) {
+ const { t } = useLocale();
+
+ return (
+
+
+ (
+
+ );
+}
diff --git a/packages/features/ee/workflows/components/agent-configuration/components/forms/VoiceSelector.tsx b/packages/features/ee/workflows/components/agent-configuration/components/forms/VoiceSelector.tsx
new file mode 100644
index 0000000000..7c2fbd1922
--- /dev/null
+++ b/packages/features/ee/workflows/components/agent-configuration/components/forms/VoiceSelector.tsx
@@ -0,0 +1,30 @@
+import { useLocale } from "@calcom/lib/hooks/useLocale";
+import { Button } from "@calcom/ui/components/button";
+import { Label } from "@calcom/ui/components/form";
+import { Icon } from "@calcom/ui/components/icon";
+
+interface VoiceSelectorProps {
+ selectedVoiceId?: string;
+ onVoiceDialogOpen: () => void;
+ disabled?: boolean;
+}
+
+export function VoiceSelector({ selectedVoiceId, onVoiceDialogOpen, disabled = false }: VoiceSelectorProps) {
+ const { t } = useLocale();
+
+ return (
+
+
+
{t("select_voice_for_agent")}
+
+
+ );
+}
diff --git a/packages/features/ee/workflows/components/agent-configuration/components/tabs/IncomingCallsTab.tsx b/packages/features/ee/workflows/components/agent-configuration/components/tabs/IncomingCallsTab.tsx
new file mode 100644
index 0000000000..453bb3a86b
--- /dev/null
+++ b/packages/features/ee/workflows/components/agent-configuration/components/tabs/IncomingCallsTab.tsx
@@ -0,0 +1,190 @@
+import { useState } from "react";
+import type { UseFormReturn } from "react-hook-form";
+
+import { useLocale } from "@calcom/lib/hooks/useLocale";
+import { PhoneNumberSubscriptionStatus } from "@calcom/prisma/enums";
+import type { RouterOutputs } from "@calcom/trpc/react";
+import { trpc } from "@calcom/trpc/react";
+import { Button } from "@calcom/ui/components/button";
+import { Label, Select } from "@calcom/ui/components/form";
+import type { MultiSelectCheckboxesOptionType as Option } from "@calcom/ui/components/form";
+import { Icon } from "@calcom/ui/components/icon";
+import { showToast } from "@calcom/ui/components/toast";
+
+import type { FormValues } from "../../../../pages/workflow";
+import { VoiceSelectionDialog } from "../../../VoiceSelectionDialog";
+import type { AgentFormValues } from "../../types/schemas";
+import { AgentConfigForm } from "../forms/AgentConfigForm";
+
+interface IncomingCallsTabProps {
+ agentData?: RouterOutputs["viewer"]["aiVoiceAgent"]["get"];
+ inboundAgentData?: RouterOutputs["viewer"]["aiVoiceAgent"]["get"];
+ inboundAgentId?: string | null;
+ inboundAgentForm: UseFormReturn;
+ form: UseFormReturn;
+ eventTypeOptions: Option[];
+ readOnly?: boolean;
+ teamId?: number;
+ workflowStepId?: number;
+}
+
+export function IncomingCallsTab({
+ agentData,
+ inboundAgentData,
+ inboundAgentId,
+ inboundAgentForm,
+ form,
+ eventTypeOptions,
+ readOnly = false,
+ teamId,
+ workflowStepId,
+}: IncomingCallsTabProps) {
+ const { t } = useLocale();
+ const utils = trpc.useUtils();
+ const [isVoiceDialogOpen, setIsVoiceDialogOpen] = useState(false);
+
+ const setupInboundAgentMutation = trpc.viewer.aiVoiceAgent.setupInboundAgent.useMutation({
+ onSuccess: async (data) => {
+ showToast(t("inbound_agent_setup_success"), "success");
+ // Update the form with the new inbound agent ID
+ if (data.agentId && form && workflowStepId) {
+ const stepIndex = form.getValues("steps")?.findIndex((s) => s.id === workflowStepId);
+ if (stepIndex !== undefined && stepIndex !== -1) {
+ form.setValue(`steps.${stepIndex}.inboundAgentId`, data.agentId, { shouldDirty: true });
+ }
+ }
+
+ // Invalidate and refetch the agent data
+ if (data.agentId) {
+ await utils.viewer.aiVoiceAgent.get.invalidate({ id: data.agentId });
+ }
+ },
+ onError: (error: { message: string }) => {
+ showToast(error.message, "error");
+ },
+ });
+
+ const updateInboundAgentEventTypeMutation =
+ trpc.viewer.aiVoiceAgent.updateInboundAgentEventType.useMutation({
+ onSuccess: async () => {
+ showToast(t("agent_event_type_updated_successfully"), "success");
+ if (inboundAgentId) {
+ await utils.viewer.aiVoiceAgent.get.invalidate({ id: inboundAgentId });
+ }
+ },
+ onError: (error: { message: string }) => {
+ showToast(error.message, "error");
+ },
+ });
+
+ const hasActivePhoneNumbers =
+ agentData?.outboundPhoneNumbers &&
+ agentData.outboundPhoneNumbers.filter(
+ (phone) =>
+ phone.subscriptionStatus === PhoneNumberSubscriptionStatus.ACTIVE || !phone.subscriptionStatus
+ ).length > 0;
+
+ if (!hasActivePhoneNumbers) {
+ return (
+
+
+
+
+
+
+
{t("setup_incoming_agent")}
+
{t("connect_a_phone_number_first")}
+
+
+
+ );
+ }
+
+ if (!inboundAgentId) {
+ return (
+
+
+
+
+
+
+
{t("setup_inbound_agent")}
+
{t("setup_inbound_agent_for_incoming_calls")}
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
{t("select_event_type_for_inbound_calls")}
+
+
+
setIsVoiceDialogOpen(true)}
+ />
+
+ {
+ inboundAgentForm.setValue("voiceId", voiceId, { shouldDirty: true });
+ }}
+ />
+
+ );
+}
diff --git a/packages/features/ee/workflows/components/agent-configuration/components/tabs/OutgoingCallsTab.tsx b/packages/features/ee/workflows/components/agent-configuration/components/tabs/OutgoingCallsTab.tsx
new file mode 100644
index 0000000000..e2164bb6ff
--- /dev/null
+++ b/packages/features/ee/workflows/components/agent-configuration/components/tabs/OutgoingCallsTab.tsx
@@ -0,0 +1,35 @@
+import { useState } from "react";
+import type { UseFormReturn } from "react-hook-form";
+
+import { VoiceSelectionDialog } from "../../../VoiceSelectionDialog";
+import type { AgentFormValues } from "../../types/schemas";
+import { AgentConfigForm } from "../forms/AgentConfigForm";
+
+interface OutgoingCallsTabProps {
+ outboundAgentForm: UseFormReturn;
+ readOnly?: boolean;
+}
+
+export function OutgoingCallsTab({ outboundAgentForm, readOnly = false }: OutgoingCallsTabProps) {
+ const [isVoiceDialogOpen, setIsVoiceDialogOpen] = useState(false);
+
+ return (
+ <>
+ setIsVoiceDialogOpen(true)}
+ />
+
+ {
+ outboundAgentForm.setValue("voiceId", voiceId, { shouldDirty: true });
+ }}
+ />
+ >
+ );
+}
diff --git a/packages/features/ee/workflows/components/agent-configuration/components/tabs/PhoneNumberTab.tsx b/packages/features/ee/workflows/components/agent-configuration/components/tabs/PhoneNumberTab.tsx
new file mode 100644
index 0000000000..8094df0f48
--- /dev/null
+++ b/packages/features/ee/workflows/components/agent-configuration/components/tabs/PhoneNumberTab.tsx
@@ -0,0 +1,350 @@
+import { useState } from "react";
+import type { UseFormReturn } from "react-hook-form";
+
+import { CAL_AI_PHONE_NUMBER_MONTHLY_PRICE } from "@calcom/lib/constants";
+import { formatPhoneNumber } from "@calcom/lib/formatPhoneNumber";
+import { useLocale } from "@calcom/lib/hooks/useLocale";
+import { PhoneNumberSubscriptionStatus } from "@calcom/prisma/enums";
+import type { RouterOutputs } from "@calcom/trpc/react";
+import { trpc } from "@calcom/trpc/react";
+import { Badge } from "@calcom/ui/components/badge";
+import { Button } from "@calcom/ui/components/button";
+import {
+ Dropdown,
+ DropdownItem,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@calcom/ui/components/dropdown";
+import { Icon } from "@calcom/ui/components/icon";
+import { showToast } from "@calcom/ui/components/toast";
+
+import type { FormValues } from "../../../../pages/workflow";
+import { TestPhoneCallDialog } from "../../../TestPhoneCallDialog";
+import { WebCallDialog } from "../../../WebCallDialog";
+import { usePhoneNumberActions } from "../../hooks/usePhoneNumberActions";
+import { BuyNumberDialog } from "../dialogs/BuyNumberDialog";
+import { ConfirmationDialogs } from "../dialogs/ConfirmationDialogs";
+import { ImportNumberDialog } from "../dialogs/ImportNumberDialog";
+
+interface PhoneNumberTabProps {
+ agentData?: RouterOutputs["viewer"]["aiVoiceAgent"]["get"];
+ readOnly?: boolean;
+ agentId?: string | null;
+ teamId?: number;
+ workflowId?: string;
+ isOrganization?: boolean;
+ form?: UseFormReturn;
+}
+
+export function PhoneNumberTab({
+ agentData,
+ readOnly = false,
+ agentId,
+ teamId,
+ workflowId,
+ isOrganization = false,
+ form,
+}: PhoneNumberTabProps) {
+ const { t } = useLocale();
+ const utils = trpc.useUtils();
+
+ const [isBuyDialogOpen, setIsBuyDialogOpen] = useState(false);
+ const [isImportDialogOpen, setIsImportDialogOpen] = useState(false);
+ const [showAdvancedFields, setShowAdvancedFields] = useState(false);
+ const [isTestAgentDialogOpen, setIsTestAgentDialogOpen] = useState(false);
+ const [isWebCallDialogOpen, setIsWebCallDialogOpen] = useState(false);
+
+ const phoneNumberActions = usePhoneNumberActions();
+
+ const buyNumberMutation = trpc.viewer.phoneNumber.buy.useMutation({
+ onSuccess: async (data: { checkoutUrl?: string; message?: string; phoneNumber?: unknown }) => {
+ if (data.checkoutUrl) {
+ window.location.href = data.checkoutUrl;
+ } else if (data.phoneNumber) {
+ showToast(t("phone_number_purchased_successfully"), "success");
+ await utils.viewer.me.get.invalidate();
+ setIsBuyDialogOpen(false);
+ if (agentId) {
+ utils.viewer.aiVoiceAgent.get.invalidate({ id: agentId });
+ }
+ } else {
+ showToast(data.message || t("something_went_wrong"), "error");
+ }
+ },
+ onError: (error: { message: string }) => {
+ showToast(error.message, "error");
+ },
+ });
+
+ const importNumberMutation = trpc.viewer.phoneNumber.import.useMutation({
+ onSuccess: async () => {
+ showToast(t("phone_number_imported_successfully"), "success");
+ setIsImportDialogOpen(false);
+
+ await utils.viewer.me.get.invalidate();
+ if (agentId) {
+ await utils.viewer.aiVoiceAgent.get.invalidate({ id: agentId });
+ }
+ },
+ onError: (error: { message: string }) => {
+ showToast(error.message, "error");
+ },
+ });
+
+ const updateAgentMutation = trpc.viewer.aiVoiceAgent.update.useMutation({
+ onSuccess: async () => {
+ if (agentId) {
+ await utils.viewer.aiVoiceAgent.get.invalidate({ id: agentId });
+ }
+ },
+ onError: (error: { message: string }) => {
+ showToast(error.message, "error");
+ },
+ });
+
+ const hasActivePhoneNumbers =
+ agentData?.outboundPhoneNumbers &&
+ agentData.outboundPhoneNumbers.filter(
+ (phone) =>
+ phone.subscriptionStatus === PhoneNumberSubscriptionStatus.ACTIVE || !phone.subscriptionStatus
+ ).length > 0;
+
+ if (hasActivePhoneNumbers) {
+ const activePhoneNumbers = agentData.outboundPhoneNumbers.filter(
+ (phone) =>
+ phone.subscriptionStatus === PhoneNumberSubscriptionStatus.ACTIVE || !phone.subscriptionStatus
+ );
+ const phoneNumber = activePhoneNumbers[0];
+
+ return (
+ <>
+
+ {updateAgentMutation.isPending && (
+
+
+
+ {t("updating")}...
+
+
+ )}
+
+
+
+
+ {formatPhoneNumber(phoneNumber.phoneNumber)}
+
+ {t("active")}
+
+ {phoneNumber.subscriptionStatus && (
+
+ {t("phone_number_cost", {
+ price: CAL_AI_PHONE_NUMBER_MONTHLY_PRICE,
+ })}
+
+ )}
+
+
+
+
+
+
+
+
+
+ {
+ setIsTestAgentDialogOpen(true);
+ }}>
+ {t("phone_call")}
+
+
+
+ {
+ setIsWebCallDialogOpen(true);
+ }}>
+ {t("web_call")}
+
+
+
+
+ {!readOnly && (
+
+
+
+
+
+
+ {
+ if (readOnly) return;
+ if (phoneNumber.subscriptionStatus === PhoneNumberSubscriptionStatus.ACTIVE) {
+ phoneNumberActions.handleCancelSubscription(phoneNumber.id);
+ } else {
+ phoneNumberActions.handleDeletePhoneNumber(phoneNumber.phoneNumber);
+ }
+ }}>
+ {phoneNumber.subscriptionStatus === PhoneNumberSubscriptionStatus.ACTIVE
+ ? t("unsubscribe")
+ : t("delete")}
+
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+ {agentId && form && (
+
+ )}
+
+ {agentId && form && (
+
+ )}
+
+
+ >
+ );
+ }
+
+ return (
+ <>
+
+
+
+
+
+
+
{t("no_phone_numbers")}
+
{t("buy_a_phone_number_or_import_one_you_already_have")}
+
+
+
+
+
+
+
+
+
+
+
+
+ {agentId && form && (
+
+ )}
+
+ {agentId && form && (
+
+ )}
+
+
+ >
+ );
+}
diff --git a/packages/features/ee/workflows/components/agent-configuration/hooks/useAgentForms.ts b/packages/features/ee/workflows/components/agent-configuration/hooks/useAgentForms.ts
new file mode 100644
index 0000000000..5c222656bc
--- /dev/null
+++ b/packages/features/ee/workflows/components/agent-configuration/hooks/useAgentForms.ts
@@ -0,0 +1,74 @@
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useEffect } from "react";
+import { useForm } from "react-hook-form";
+
+import type { RouterOutputs } from "@calcom/trpc/react";
+
+import type { AgentFormValues } from "../types/schemas";
+import { agentSchema } from "../types/schemas";
+import { cleanPromptForDisplay } from "../utils/promptUtils";
+
+interface UseAgentFormsProps {
+ agentData?: RouterOutputs["viewer"]["aiVoiceAgent"]["get"];
+ inboundAgentData?: RouterOutputs["viewer"]["aiVoiceAgent"]["get"];
+}
+
+export function useAgentForms({ agentData, inboundAgentData }: UseAgentFormsProps) {
+ const outboundAgentForm = useForm({
+ resolver: zodResolver(agentSchema),
+ defaultValues: {
+ generalPrompt: "",
+ beginMessage: "",
+ numberToCall: "",
+ language: "en-US",
+ voiceId: "",
+ generalTools: [],
+ },
+ });
+
+ const inboundAgentForm = useForm({
+ resolver: zodResolver(agentSchema),
+ defaultValues: {
+ generalPrompt: "",
+ beginMessage: "",
+ numberToCall: "",
+ language: "en-US",
+ voiceId: "",
+ generalTools: [],
+ },
+ });
+
+ useEffect(() => {
+ if (agentData?.retellData) {
+ const retellData = agentData.retellData;
+ outboundAgentForm.reset({
+ generalPrompt: cleanPromptForDisplay(retellData.generalPrompt || ""),
+ beginMessage: retellData.beginMessage || "",
+ numberToCall: "",
+ language: retellData.language || "en-US",
+ voiceId: retellData.voiceId || "",
+ generalTools: retellData.generalTools || [],
+ });
+ }
+ }, [agentData, outboundAgentForm]);
+
+ useEffect(() => {
+ if (inboundAgentData?.retellData) {
+ const retellData = inboundAgentData.retellData;
+
+ inboundAgentForm.reset({
+ generalPrompt: cleanPromptForDisplay(retellData.generalPrompt || ""),
+ beginMessage: retellData.beginMessage || "",
+ numberToCall: "",
+ language: retellData.language || "en-US",
+ voiceId: retellData.voiceId || "",
+ generalTools: retellData.generalTools || [],
+ });
+ }
+ }, [inboundAgentData, inboundAgentForm]);
+
+ return {
+ outboundAgentForm,
+ inboundAgentForm,
+ };
+}
diff --git a/packages/features/ee/workflows/components/agent-configuration/hooks/usePhoneNumberActions.ts b/packages/features/ee/workflows/components/agent-configuration/hooks/usePhoneNumberActions.ts
new file mode 100644
index 0000000000..18c6fcb4ac
--- /dev/null
+++ b/packages/features/ee/workflows/components/agent-configuration/hooks/usePhoneNumberActions.ts
@@ -0,0 +1,40 @@
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useState } from "react";
+import { useForm } from "react-hook-form";
+
+import type { PhoneNumberFormValues } from "../types/schemas";
+import { phoneNumberFormSchema } from "../types/schemas";
+
+export function usePhoneNumberActions() {
+ const [cancellingNumberId, setCancellingNumberId] = useState(null);
+ const [numberToDelete, setNumberToDelete] = useState(null);
+
+ const phoneNumberForm = useForm({
+ resolver: zodResolver(phoneNumberFormSchema),
+ defaultValues: {
+ phoneNumber: "",
+ terminationUri: "",
+ sipTrunkAuthUsername: "",
+ sipTrunkAuthPassword: "",
+ nickname: "",
+ },
+ });
+
+ const handleCancelSubscription = (phoneNumberId: number) => {
+ setCancellingNumberId(phoneNumberId);
+ };
+
+ const handleDeletePhoneNumber = (phoneNumber: string) => {
+ setNumberToDelete(phoneNumber);
+ };
+
+ return {
+ phoneNumberForm,
+ cancellingNumberId,
+ setCancellingNumberId,
+ numberToDelete,
+ setNumberToDelete,
+ handleCancelSubscription,
+ handleDeletePhoneNumber,
+ };
+}
diff --git a/packages/features/ee/workflows/components/agent-configuration/types/schemas.ts b/packages/features/ee/workflows/components/agent-configuration/types/schemas.ts
new file mode 100644
index 0000000000..605d898dda
--- /dev/null
+++ b/packages/features/ee/workflows/components/agent-configuration/types/schemas.ts
@@ -0,0 +1,45 @@
+import { isValidPhoneNumber } from "libphonenumber-js";
+import { z } from "zod";
+
+export const agentSchema = z.object({
+ generalPrompt: z.string().min(1, "General prompt is required"),
+ beginMessage: z.string().min(1, "Begin message is required"),
+ numberToCall: z.string().optional(),
+ language: z.string().optional(),
+ voiceId: z.string().optional(),
+ generalTools: z
+ .array(
+ z.object({
+ type: z.string(),
+ name: z.string(),
+ description: z.string().nullish().default(null),
+ cal_api_key: z.string().nullish().default(null),
+ event_type_id: z.number().nullish().default(null),
+ timezone: z.string().nullish().default(null),
+ })
+ )
+ .optional(),
+});
+
+export const phoneNumberFormSchema = z.object({
+ phoneNumber: z.string().refine((val) => isValidPhoneNumber(val)),
+ terminationUri: z.string().min(1, "Termination URI is required"),
+ sipTrunkAuthUsername: z.string().optional(),
+ sipTrunkAuthPassword: z.string().optional(),
+ nickname: z.string().optional(),
+});
+
+export type AgentFormValues = z.infer;
+export type PhoneNumberFormValues = z.infer;
+
+// Commented types for future implementation
+// type RetellData = RouterOutputs["viewer"]["ai"]["get"]["retellData"];
+
+// type ToolDraft = {
+// type: string;
+// name: string;
+// description: string | null;
+// cal_api_key: string | null;
+// event_type_id: number | null;
+// timezone: string;
+// };
diff --git a/packages/features/ee/workflows/components/agent-configuration/utils/constants.ts b/packages/features/ee/workflows/components/agent-configuration/utils/constants.ts
new file mode 100644
index 0000000000..9ced8fb2e6
--- /dev/null
+++ b/packages/features/ee/workflows/components/agent-configuration/utils/constants.ts
@@ -0,0 +1,38 @@
+export const LANGUAGE_OPTIONS = [
+ { value: "en-US", label: "English (US)" },
+ { value: "en-IN", label: "English (India)" },
+ { value: "en-GB", label: "English (UK)" },
+ { value: "en-AU", label: "English (Australia)" },
+ { value: "en-NZ", label: "English (New Zealand)" },
+ { value: "de-DE", label: "German" },
+ { value: "es-ES", label: "Spanish (Spain)" },
+ { value: "es-419", label: "Spanish (Latin America)" },
+ { value: "hi-IN", label: "Hindi" },
+ { value: "fr-FR", label: "French (France)" },
+ { value: "fr-CA", label: "French (Canada)" },
+ { value: "ja-JP", label: "Japanese" },
+ { value: "pt-PT", label: "Portuguese (Portugal)" },
+ { value: "pt-BR", label: "Portuguese (Brazil)" },
+ { value: "zh-CN", label: "Chinese (Simplified)" },
+ { value: "ru-RU", label: "Russian" },
+ { value: "it-IT", label: "Italian" },
+ { value: "ko-KR", label: "Korean" },
+ { value: "nl-NL", label: "Dutch (Netherlands)" },
+ { value: "nl-BE", label: "Dutch (Belgium)" },
+ { value: "pl-PL", label: "Polish" },
+ { value: "tr-TR", label: "Turkish" },
+ { value: "th-TH", label: "Thai" },
+ { value: "vi-VN", label: "Vietnamese" },
+ { value: "ro-RO", label: "Romanian" },
+ { value: "bg-BG", label: "Bulgarian" },
+ { value: "ca-ES", label: "Catalan" },
+ { value: "da-DK", label: "Danish" },
+ { value: "fi-FI", label: "Finnish" },
+ { value: "el-GR", label: "Greek" },
+ { value: "hu-HU", label: "Hungarian" },
+ { value: "id-ID", label: "Indonesian" },
+ { value: "no-NO", label: "Norwegian" },
+ { value: "sk-SK", label: "Slovak" },
+ { value: "sv-SE", label: "Swedish" },
+ { value: "multi", label: "Multilingual" },
+];
diff --git a/packages/features/ee/workflows/components/agent-configuration/utils/promptUtils.ts b/packages/features/ee/workflows/components/agent-configuration/utils/promptUtils.ts
new file mode 100644
index 0000000000..518cae6116
--- /dev/null
+++ b/packages/features/ee/workflows/components/agent-configuration/utils/promptUtils.ts
@@ -0,0 +1,40 @@
+// Utility functions for prompt display and restoration
+import { RETELL_AI_TEST_MODE, RETELL_AI_TEST_EVENT_TYPE_MAP } from "@calcom/lib/constants";
+
+export const cleanPromptForDisplay = (prompt: string): string => {
+ if (!prompt) return prompt;
+
+ const cleanedPrompt = prompt
+ .replace(/check_availability_\{\{eventTypeId\}\}/g, "check_availability")
+ .replace(/book_appointment_\{\{eventTypeId\}\}/g, "book_appointment")
+ .replace(/check_availability_\d+/g, "check_availability")
+ .replace(/book_appointment_\d+/g, "book_appointment");
+
+ return cleanedPrompt;
+};
+
+export const restorePromptComplexity = (prompt: string): string => {
+ if (!prompt) return prompt;
+
+ return prompt
+ .replace(/\bcheck_availability\b/g, "check_availability_{{eventTypeId}}")
+ .replace(/\bbook_appointment\b/g, "book_appointment_{{eventTypeId}}");
+};
+
+/**
+ * Replaces event type placeholders in a prompt with the actual event type ID
+ * Handles both numeric placeholders (check_availability_123) and template placeholders (check_availability_{{eventTypeId}})
+ */
+export const replaceEventTypePlaceholders = (prompt: string, eventTypeId: number): string => {
+ let eventTypeIdToUse = eventTypeId;
+ if (RETELL_AI_TEST_MODE && RETELL_AI_TEST_EVENT_TYPE_MAP) {
+ const mappedId = RETELL_AI_TEST_EVENT_TYPE_MAP[String(eventTypeId)];
+ eventTypeIdToUse = mappedId ? Number(mappedId) : eventTypeId;
+ }
+
+ return prompt
+ .replace(/check_availability_\d+/g, `check_availability_${eventTypeIdToUse}`)
+ .replace(/book_appointment_\d+/g, `book_appointment_${eventTypeIdToUse}`)
+ .replace(/check_availability_\{\{eventTypeId\}\}/g, `check_availability_${eventTypeIdToUse}`)
+ .replace(/book_appointment_\{\{eventTypeId\}\}/g, `book_appointment_${eventTypeIdToUse}`);
+};
diff --git a/packages/features/ee/workflows/hooks/useAgentsData.ts b/packages/features/ee/workflows/hooks/useAgentsData.ts
new file mode 100644
index 0000000000..ef1dee2846
--- /dev/null
+++ b/packages/features/ee/workflows/hooks/useAgentsData.ts
@@ -0,0 +1,107 @@
+import { useMemo } from "react";
+import type { UseFormReturn } from "react-hook-form";
+import { useWatch } from "react-hook-form";
+
+import type { RouterOutputs } from "@calcom/trpc/react";
+import { trpc } from "@calcom/trpc/react";
+
+import type { FormValues } from "../pages/workflow";
+
+type AgentData = RouterOutputs["viewer"]["aiVoiceAgent"]["get"];
+
+interface AgentQuery {
+ data: AgentData | undefined;
+ isPending: boolean;
+ isLoading: boolean;
+ error: Error | null;
+}
+
+interface UseAgentsDataReturn {
+ outboundAgentQueries: AgentQuery[];
+ inboundAgentQueries: AgentQuery[];
+}
+
+const emptyQuery: AgentQuery = { data: undefined, isPending: false, isLoading: false, error: null };
+
+/**
+ * Custom hook to fetch and manage agent data for workflow steps.
+ *
+ * @param form - React Hook Form instance for workflow form values
+ * @returns Object containing outbound and inbound agent queries with proper typing
+ */
+export function useAgentsData(form: UseFormReturn): UseAgentsDataReturn {
+ const watchedSteps = useWatch({
+ control: form.control,
+ name: "steps",
+ });
+
+ const allAgentIds = useMemo(() => {
+ const outboundAgentIds = (watchedSteps || []).map((step) => step?.agentId || null);
+ const inboundAgentIds = (watchedSteps || []).map((step) => step?.inboundAgentId || null);
+ return { outboundAgentIds, inboundAgentIds };
+ }, [watchedSteps]);
+
+ const uniqueAgentIds = useMemo((): string[] => {
+ const allIds = [...allAgentIds.outboundAgentIds, ...allAgentIds.inboundAgentIds];
+ const validIds: string[] = [];
+
+ for (const id of allIds) {
+ if (id && typeof id === "string") {
+ validIds.push(id);
+ }
+ }
+
+ return Array.from(new Set(validIds));
+ }, [allAgentIds]);
+
+ const uniqueAgentQueries = trpc.useQueries((t) =>
+ uniqueAgentIds.map((agentId) => {
+ return t.viewer.aiVoiceAgent.get(
+ { id: agentId },
+ {
+ enabled: true,
+ staleTime: 5 * 60 * 1000,
+ }
+ );
+ })
+ );
+
+ const agentDataMap = useMemo(() => {
+ const map = new Map();
+ uniqueAgentIds.forEach((agentId, index) => {
+ const query = uniqueAgentQueries[index];
+ if (query) {
+ map.set(agentId, {
+ data: query.data,
+ isPending: query.isPending,
+ isLoading: query.isLoading,
+ error: query.error,
+ });
+ }
+ });
+ return map;
+ }, [uniqueAgentIds, uniqueAgentQueries]);
+
+ const outboundAgentQueries = useMemo((): AgentQuery[] => {
+ return allAgentIds.outboundAgentIds.map((agentId) => {
+ if (!agentId) {
+ return emptyQuery;
+ }
+ return agentDataMap.get(agentId) || emptyQuery;
+ });
+ }, [allAgentIds.outboundAgentIds, agentDataMap]);
+
+ const inboundAgentQueries = useMemo((): AgentQuery[] => {
+ return allAgentIds.inboundAgentIds.map((agentId) => {
+ if (!agentId) {
+ return emptyQuery;
+ }
+ return agentDataMap.get(agentId) || emptyQuery;
+ });
+ }, [allAgentIds.inboundAgentIds, agentDataMap]);
+
+ return {
+ outboundAgentQueries,
+ inboundAgentQueries,
+ };
+}
diff --git a/packages/features/ee/workflows/pages/workflow.tsx b/packages/features/ee/workflows/pages/workflow.tsx
index d9f753ad22..1b6d64fb64 100644
--- a/packages/features/ee/workflows/pages/workflow.tsx
+++ b/packages/features/ee/workflows/pages/workflow.tsx
@@ -38,7 +38,11 @@ import { getTranslatedText, translateVariablesToEnglish } from "../lib/variableT
export type FormValues = {
name: string;
activeOn: Option[];
- steps: (WorkflowStep & { senderName: string | null; agentId?: string | null })[];
+ steps: (WorkflowStep & {
+ senderName: string | null;
+ agentId?: string | null;
+ inboundAgentId?: string | null;
+ })[];
trigger: WorkflowTriggerEvents;
time?: number;
timeUnit?: TimeUnit;
diff --git a/packages/lib/server/repository/PrismaAgentRepository.ts b/packages/lib/server/repository/PrismaAgentRepository.ts
index 21bec58da8..84d2e799ea 100644
--- a/packages/lib/server/repository/PrismaAgentRepository.ts
+++ b/packages/lib/server/repository/PrismaAgentRepository.ts
@@ -1,5 +1,4 @@
-import prisma from "@calcom/prisma";
-import { Prisma } from "@calcom/prisma/client";
+import { Prisma, PrismaClient } from "@calcom/prisma/client";
import { MembershipRole } from "@calcom/prisma/enums";
interface _AgentRawResult {
@@ -9,6 +8,7 @@ interface _AgentRawResult {
enabled: boolean;
userId: number;
teamId: number | null;
+ inboundEventTypeId?: number | null;
createdAt: Date;
updatedAt: Date;
user_id?: number;
@@ -29,8 +29,10 @@ interface _PhoneNumberRawResult {
}
export class PrismaAgentRepository {
- private static async getUserAccessibleTeamIds(userId: number): Promise {
- const memberships = await prisma.membership.findMany({
+ constructor(private prismaClient: PrismaClient) {}
+
+ private async getUserAccessibleTeamIds(userId: number): Promise {
+ const memberships = await this.prismaClient.membership.findMany({
where: {
userId,
accepted: true,
@@ -43,8 +45,8 @@ export class PrismaAgentRepository {
return memberships.map((membership) => membership.teamId);
}
- private static async getUserAdminTeamIds(userId: number): Promise {
- const memberships = await prisma.membership.findMany({
+ private async getUserAdminTeamIds(userId: number): Promise {
+ const memberships = await this.prismaClient.membership.findMany({
where: {
userId,
accepted: true,
@@ -60,7 +62,7 @@ export class PrismaAgentRepository {
return memberships.map((membership) => membership.teamId);
}
- static async findByIdWithUserAccess({
+ async findByIdWithUserAccess({
agentId,
userId,
teamId,
@@ -98,6 +100,7 @@ export class PrismaAgentRepository {
enabled,
"userId",
"teamId",
+ "inboundEventTypeId",
"createdAt",
"updatedAt"
FROM "Agent"
@@ -105,12 +108,12 @@ export class PrismaAgentRepository {
LIMIT 1
`;
- const agents = await prisma.$queryRaw<_AgentRawResult[]>(query);
+ const agents = await this.prismaClient.$queryRaw<_AgentRawResult[]>(query);
return agents.length > 0 ? agents[0] : null;
}
- static async findByProviderAgentIdWithUserAccess({
+ async findByProviderAgentIdWithUserAccess({
providerAgentId,
userId,
}: {
@@ -136,6 +139,7 @@ export class PrismaAgentRepository {
enabled,
"userId",
"teamId",
+ "inboundEventTypeId",
"createdAt",
"updatedAt"
FROM "Agent"
@@ -143,13 +147,13 @@ export class PrismaAgentRepository {
LIMIT 1
`;
- const agents = await prisma.$queryRaw<_AgentRawResult[]>(query);
+ const agents = await this.prismaClient.$queryRaw<_AgentRawResult[]>(query);
return agents.length > 0 ? agents[0] : null;
}
- static async findById({ id }: { id: string }) {
- return await prisma.agent.findUnique({
+ async findById({ id }: { id: string }) {
+ return await this.prismaClient.agent.findUnique({
select: {
id: true,
name: true,
@@ -157,6 +161,7 @@ export class PrismaAgentRepository {
enabled: true,
userId: true,
teamId: true,
+ inboundEventTypeId: true,
createdAt: true,
updatedAt: true,
},
@@ -166,8 +171,8 @@ export class PrismaAgentRepository {
});
}
- static async findByProviderAgentId({ providerAgentId }: { providerAgentId: string }) {
- return await prisma.agent.findUnique({
+ async findByProviderAgentId({ providerAgentId }: { providerAgentId: string }) {
+ return await this.prismaClient.agent.findUnique({
select: {
id: true,
name: true,
@@ -175,6 +180,7 @@ export class PrismaAgentRepository {
enabled: true,
userId: true,
teamId: true,
+ inboundEventTypeId: true,
createdAt: true,
updatedAt: true,
},
@@ -184,7 +190,7 @@ export class PrismaAgentRepository {
});
}
- static async findManyWithUserAccess({
+ async findManyWithUserAccess({
userId,
teamId,
scope = "all",
@@ -239,6 +245,7 @@ export class PrismaAgentRepository {
a.enabled,
a."userId",
a."teamId",
+ a."inboundEventTypeId",
a."createdAt",
a."updatedAt",
u.id as user_id,
@@ -255,13 +262,13 @@ export class PrismaAgentRepository {
ORDER BY a."teamId" ASC, a."createdAt" DESC
`;
- const agents = await prisma.$queryRaw<_AgentRawResult[]>(query);
+ const agents = await this.prismaClient.$queryRaw<_AgentRawResult[]>(query);
// Get phone numbers for each agent in a separate query to avoid N+1
const agentIds = agents.map((agent) => agent.id);
const phoneNumbers =
agentIds.length > 0
- ? await prisma.$queryRaw<_PhoneNumberRawResult[]>`
+ ? await this.prismaClient.$queryRaw<_PhoneNumberRawResult[]>`
SELECT
pn.id,
pn."phoneNumber",
@@ -298,6 +305,7 @@ export class PrismaAgentRepository {
enabled: agent.enabled,
userId: agent.userId,
teamId: agent.teamId,
+ inboundEventTypeId: agent.inboundEventTypeId,
createdAt: agent.createdAt,
updatedAt: agent.updatedAt,
user: agent.user_id
@@ -319,7 +327,7 @@ export class PrismaAgentRepository {
}));
}
- static async findByIdWithUserAccessAndDetails({
+ async findByIdWithUserAccessAndDetails({
id,
userId,
teamId,
@@ -357,6 +365,7 @@ export class PrismaAgentRepository {
a.enabled,
a."userId",
a."teamId",
+ a."inboundEventTypeId",
a."createdAt",
a."updatedAt",
u.id as user_id,
@@ -372,7 +381,7 @@ export class PrismaAgentRepository {
LIMIT 1
`;
- const agents = await prisma.$queryRaw<_AgentRawResult[]>(query);
+ const agents = await this.prismaClient.$queryRaw<_AgentRawResult[]>(query);
if (agents.length === 0) {
return null;
@@ -380,7 +389,7 @@ export class PrismaAgentRepository {
const agent = agents[0];
- const phoneNumbers = await prisma.$queryRaw<_PhoneNumberRawResult[]>`
+ const phoneNumbers = await this.prismaClient.$queryRaw<_PhoneNumberRawResult[]>`
SELECT
pn.id,
pn."phoneNumber",
@@ -398,6 +407,7 @@ export class PrismaAgentRepository {
enabled: agent.enabled,
userId: agent.userId,
teamId: agent.teamId,
+ inboundEventTypeId: agent.inboundEventTypeId,
createdAt: agent.createdAt,
updatedAt: agent.updatedAt,
user: agent.user_id
@@ -423,7 +433,7 @@ export class PrismaAgentRepository {
};
}
- static async create({
+ async create({
name,
providerAgentId,
userId,
@@ -434,7 +444,7 @@ export class PrismaAgentRepository {
userId: number;
teamId?: number;
}) {
- return await prisma.agent.create({
+ return await this.prismaClient.agent.create({
data: {
name,
providerAgentId,
@@ -444,7 +454,7 @@ export class PrismaAgentRepository {
});
}
- static async findByIdWithAdminAccess({
+ async findByIdWithAdminAccess({
id,
userId,
teamId,
@@ -481,18 +491,19 @@ export class PrismaAgentRepository {
"userId",
"teamId",
"createdAt",
- "updatedAt"
+ "updatedAt",
+ "inboundEventTypeId"
FROM "Agent"
WHERE ${whereCondition}
LIMIT 1
`;
- const agents = await prisma.$queryRaw<_AgentRawResult[]>(query);
+ const agents = await this.prismaClient.$queryRaw<_AgentRawResult[]>(query);
return agents.length > 0 ? agents[0] : null;
}
- static async findByIdWithCallAccess({ id, userId }: { id: string; userId: number }) {
+ async findByIdWithCallAccess({ id, userId }: { id: string; userId: number }) {
const accessibleTeamIds = await this.getUserAccessibleTeamIds(userId);
let whereCondition: Prisma.Sql;
@@ -512,6 +523,7 @@ export class PrismaAgentRepository {
a.enabled,
a."userId",
a."teamId",
+ a."inboundEventTypeId",
a."createdAt",
a."updatedAt"
FROM "Agent" a
@@ -519,7 +531,7 @@ export class PrismaAgentRepository {
LIMIT 1
`;
- const agents = await prisma.$queryRaw<_AgentRawResult[]>(query);
+ const agents = await this.prismaClient.$queryRaw<_AgentRawResult[]>(query);
if (agents.length === 0) {
return null;
@@ -527,7 +539,7 @@ export class PrismaAgentRepository {
const agent = agents[0];
- const phoneNumbers = await prisma.$queryRaw<{ phoneNumber: string }[]>`
+ const phoneNumbers = await this.prismaClient.$queryRaw<{ phoneNumber: string }[]>`
SELECT "phoneNumber"
FROM "CalAiPhoneNumber"
WHERE "outboundAgentId" = ${agent.id}
@@ -539,27 +551,61 @@ export class PrismaAgentRepository {
};
}
- static async delete({ id }: { id: string }) {
- return await prisma.agent.delete({
+ async delete({ id }: { id: string }) {
+ return await this.prismaClient.agent.delete({
where: { id },
});
}
- static async linkToWorkflowStep({ workflowStepId, agentId }: { workflowStepId: number; agentId: string }) {
- return await prisma.workflowStep.update({
+ async linkOutboundAgentToWorkflow({
+ workflowStepId,
+ agentId,
+ }: {
+ workflowStepId: number;
+ agentId: string;
+ }) {
+ return await this.prismaClient.workflowStep.update({
where: { id: workflowStepId },
data: { agentId },
});
}
- static async canManageTeamResources({
+ async linkInboundAgentToWorkflow({
+ workflowStepId,
+ agentId,
+ }: {
+ workflowStepId: number;
+ agentId: string;
+ }) {
+ return await this.prismaClient.workflowStep.update({
+ where: {
+ id: workflowStepId,
+ },
+ data: {
+ inboundAgentId: agentId,
+ },
+ });
+ }
+
+ async updateEventTypeId({ agentId, eventTypeId }: { agentId: string; eventTypeId: number }) {
+ return await this.prismaClient.agent.update({
+ where: {
+ id: agentId,
+ },
+ data: {
+ inboundEventTypeId: eventTypeId,
+ },
+ });
+ }
+
+ async canManageTeamResources({
userId,
teamId,
}: {
userId: number;
teamId: number;
}): Promise {
- const result = await prisma.$queryRaw<{ count: bigint }[]>`
+ const result = await this.prismaClient.$queryRaw<{ count: bigint }[]>`
SELECT COUNT(*) as count
FROM "Membership"
WHERE "userId" = ${userId}
diff --git a/packages/lib/server/repository/PrismaPhoneNumberRepository.ts b/packages/lib/server/repository/PrismaPhoneNumberRepository.ts
index 0d7e1af1c0..d56a424bb7 100644
--- a/packages/lib/server/repository/PrismaPhoneNumberRepository.ts
+++ b/packages/lib/server/repository/PrismaPhoneNumberRepository.ts
@@ -1,4 +1,4 @@
-import { prisma } from "@calcom/prisma";
+import { PrismaClient } from "@calcom/prisma";
import { Prisma } from "@calcom/prisma/client";
import { PhoneNumberSubscriptionStatus } from "@calcom/prisma/enums";
@@ -24,8 +24,10 @@ export interface AgentRawResult {
}
export class PrismaPhoneNumberRepository {
- private static async getUserAccessibleTeamIds(userId: number): Promise {
- const memberships = await prisma.membership.findMany({
+ constructor(private prismaClient: PrismaClient) {}
+
+ private async getUserAccessibleTeamIds(userId: number): Promise {
+ const memberships = await this.prismaClient.membership.findMany({
where: {
userId,
accepted: true,
@@ -38,8 +40,8 @@ export class PrismaPhoneNumberRepository {
return memberships.map((membership) => membership.teamId);
}
- static async findByPhoneNumberAndUserId({ phoneNumber, userId }: { phoneNumber: string; userId: number }) {
- return await prisma.calAiPhoneNumber.findFirstOrThrow({
+ async findByPhoneNumberAndUserId({ phoneNumber, userId }: { phoneNumber: string; userId: number }) {
+ return await this.prismaClient.calAiPhoneNumber.findFirstOrThrow({
where: {
phoneNumber,
userId,
@@ -61,8 +63,8 @@ export class PrismaPhoneNumberRepository {
});
}
- static async findPhoneNumbersFromUserId({ userId }: { userId: number }) {
- const phoneNumbers = await prisma.$queryRaw<_PhoneNumberRawResult[]>`
+ async findPhoneNumbersFromUserId({ userId }: { userId: number }) {
+ const phoneNumbers = await this.prismaClient.$queryRaw<_PhoneNumberRawResult[]>`
SELECT
pn.id,
pn."phoneNumber",
@@ -84,7 +86,7 @@ export class PrismaPhoneNumberRepository {
const phoneNumberIds = phoneNumbers.map((pn) => pn.id);
const agents =
phoneNumberIds.length > 0
- ? await prisma.$queryRaw<(AgentRawResult & { phoneNumberId: number; agentType: string })[]>`
+ ? await this.prismaClient.$queryRaw<(AgentRawResult & { phoneNumberId: number; agentType: string })[]>`
SELECT
a.id,
a.name,
@@ -137,7 +139,7 @@ export class PrismaPhoneNumberRepository {
}));
}
- static async createPhoneNumber({
+ async createPhoneNumber({
phoneNumber,
provider,
userId,
@@ -156,7 +158,7 @@ export class PrismaPhoneNumberRepository {
subscriptionStatus?: PhoneNumberSubscriptionStatus;
providerPhoneNumberId?: string;
}) {
- return await prisma.calAiPhoneNumber.create({
+ return await this.prismaClient.calAiPhoneNumber.create({
select: {
id: true,
phoneNumber: true,
@@ -185,16 +187,16 @@ export class PrismaPhoneNumberRepository {
});
}
- static async deletePhoneNumber({ phoneNumber }: { phoneNumber: string }) {
- return await prisma.calAiPhoneNumber.delete({
+ async deletePhoneNumber({ phoneNumber }: { phoneNumber: string }) {
+ return await this.prismaClient.calAiPhoneNumber.delete({
where: {
phoneNumber,
},
});
}
- static async findByStripeSubscriptionId({ stripeSubscriptionId }: { stripeSubscriptionId: string }) {
- return await prisma.calAiPhoneNumber.findFirst({
+ async findByStripeSubscriptionId({ stripeSubscriptionId }: { stripeSubscriptionId: string }) {
+ return await this.prismaClient.calAiPhoneNumber.findFirst({
where: {
stripeSubscriptionId,
},
@@ -211,8 +213,8 @@ export class PrismaPhoneNumberRepository {
});
}
- static async findByIdAndUserId({ id, userId }: { id: number; userId: number }) {
- return await prisma.calAiPhoneNumber.findFirst({
+ async findByIdAndUserId({ id, userId }: { id: number; userId: number }) {
+ return await this.prismaClient.calAiPhoneNumber.findFirst({
where: {
id,
userId,
@@ -234,7 +236,7 @@ export class PrismaPhoneNumberRepository {
});
}
- static async findByIdWithTeamAccess({
+ async findByIdWithTeamAccess({
id,
teamId,
userId,
@@ -249,7 +251,7 @@ export class PrismaPhoneNumberRepository {
return null;
}
- return await prisma.calAiPhoneNumber.findFirst({
+ return await this.prismaClient.calAiPhoneNumber.findFirst({
where: {
id,
teamId,
@@ -271,7 +273,7 @@ export class PrismaPhoneNumberRepository {
});
}
- static async findByPhoneNumberAndTeamId({
+ async findByPhoneNumberAndTeamId({
phoneNumber,
teamId,
userId,
@@ -286,7 +288,7 @@ export class PrismaPhoneNumberRepository {
return null;
}
- return await prisma.calAiPhoneNumber.findFirst({
+ return await this.prismaClient.calAiPhoneNumber.findFirst({
where: {
phoneNumber,
teamId,
@@ -308,7 +310,7 @@ export class PrismaPhoneNumberRepository {
});
}
- static async findManyWithUserAccess({
+ async findManyWithUserAccess({
userId,
teamId,
scope = "all",
@@ -373,12 +375,12 @@ export class PrismaPhoneNumberRepository {
ORDER BY pn."createdAt" DESC
`;
- const phoneNumbers = await prisma.$queryRaw<_PhoneNumberRawResult[]>(query);
+ const phoneNumbers = await this.prismaClient.$queryRaw<_PhoneNumberRawResult[]>(query);
const phoneNumberIds = phoneNumbers.map((pn) => pn.id);
const agents =
phoneNumberIds.length > 0
- ? await prisma.$queryRaw<(AgentRawResult & { phoneNumberId: number; agentType: string })[]>`
+ ? await this.prismaClient.$queryRaw<(AgentRawResult & { phoneNumberId: number; agentType: string })[]>`
SELECT
a.id,
a.name,
@@ -431,26 +433,29 @@ export class PrismaPhoneNumberRepository {
}));
}
- static async updateSubscriptionStatus({
+ async updateSubscriptionStatus({
id,
subscriptionStatus,
- disconnectOutboundAgent = false,
+ disconnectAgents = false,
}: {
id: number;
subscriptionStatus: PhoneNumberSubscriptionStatus;
- disconnectOutboundAgent?: boolean;
+ disconnectAgents?: boolean;
}) {
const updateData: Prisma.CalAiPhoneNumberUpdateInput = {
subscriptionStatus,
};
- if (disconnectOutboundAgent) {
+ if (disconnectAgents) {
updateData.outboundAgent = {
disconnect: true,
};
+ updateData.inboundAgent = {
+ disconnect: true,
+ };
}
- return await prisma.calAiPhoneNumber.update({
+ return await this.prismaClient.calAiPhoneNumber.update({
where: {
id,
},
@@ -458,7 +463,7 @@ export class PrismaPhoneNumberRepository {
});
}
- static async updateAgents({
+ async updateAgents({
id,
inboundProviderAgentId,
outboundProviderAgentId,
@@ -471,7 +476,7 @@ export class PrismaPhoneNumberRepository {
if (inboundProviderAgentId !== undefined) {
if (inboundProviderAgentId) {
- const agent = await prisma.agent.findFirst({
+ const agent = await this.prismaClient.agent.findFirst({
where: {
providerAgentId: inboundProviderAgentId,
},
@@ -491,7 +496,7 @@ export class PrismaPhoneNumberRepository {
if (outboundProviderAgentId !== undefined) {
if (outboundProviderAgentId) {
- const agent = await prisma.agent.findFirst({
+ const agent = await this.prismaClient.agent.findFirst({
where: {
providerAgentId: outboundProviderAgentId,
},
@@ -509,7 +514,7 @@ export class PrismaPhoneNumberRepository {
}
}
- return await prisma.calAiPhoneNumber.update({
+ return await this.prismaClient.calAiPhoneNumber.update({
where: {
id,
},
@@ -517,8 +522,32 @@ export class PrismaPhoneNumberRepository {
});
}
- static async findByPhoneNumber({ phoneNumber }: { phoneNumber: string }) {
- return await prisma.calAiPhoneNumber.findFirst({
+ async updateInboundAgentId({
+ id,
+ agentId,
+ }: {
+ id: number;
+ agentId: string;
+ }) {
+ // Atomic update: only set if inboundAgentId is currently null
+ return await this.prismaClient.calAiPhoneNumber.updateMany({
+ where: {
+ id,
+ inboundAgentId: null,
+ },
+ data: { inboundAgentId: agentId },
+ });
+ }
+
+ async findInboundAgentIdByPhoneNumberId({ phoneNumberId }: { phoneNumberId: number }) {
+ return await this.prismaClient.calAiPhoneNumber.findUnique({
+ where: { id: phoneNumberId },
+ select: { inboundAgentId: true },
+ });
+ }
+
+ async findByPhoneNumber({ phoneNumber }: { phoneNumber: string }) {
+ return await this.prismaClient.calAiPhoneNumber.findFirst({
where: {
phoneNumber,
},
diff --git a/packages/prisma/migrations/20251006103705_add_inbound_agent/migration.sql b/packages/prisma/migrations/20251006103705_add_inbound_agent/migration.sql
new file mode 100644
index 0000000000..59c7f4f1cb
--- /dev/null
+++ b/packages/prisma/migrations/20251006103705_add_inbound_agent/migration.sql
@@ -0,0 +1,20 @@
+/*
+ Warnings:
+
+ - A unique constraint covering the columns `[inboundAgentId]` on the table `WorkflowStep` will be added. If there are existing duplicate values, this will fail.
+
+*/
+-- AlterTable
+ALTER TABLE "Agent" ADD COLUMN "inboundEventTypeId" INTEGER;
+
+-- AlterTable
+ALTER TABLE "WorkflowStep" ADD COLUMN "inboundAgentId" TEXT;
+
+-- CreateIndex
+CREATE INDEX "Agent_inboundEventTypeId_idx" ON "Agent"("inboundEventTypeId");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "WorkflowStep_inboundAgentId_key" ON "WorkflowStep"("inboundAgentId");
+
+-- AddForeignKey
+ALTER TABLE "WorkflowStep" ADD CONSTRAINT "WorkflowStep_inboundAgentId_fkey" FOREIGN KEY ("inboundAgentId") REFERENCES "Agent"("id") ON DELETE SET NULL ON UPDATE CASCADE;
diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma
index 68783b98f0..8f694e2e50 100644
--- a/packages/prisma/schema.prisma
+++ b/packages/prisma/schema.prisma
@@ -247,6 +247,7 @@ model EventType {
createdAt DateTime? @default(now())
updatedAt DateTime? @updatedAt
+
@@unique([userId, slug])
@@unique([teamId, slug])
@@unique([userId, parentId])
@@ -1426,6 +1427,9 @@ model WorkflowStep {
agentId String? @unique
agent Agent? @relation(fields: [agentId], references: [id], onDelete: SetNull)
+ inboundAgentId String? @unique
+ inboundAgent Agent? @relation("WorkflowStepInboundAgent", fields: [inboundAgentId], references: [id], onDelete: SetNull)
+
@@index([workflowId])
}
@@ -2538,6 +2542,7 @@ enum PhoneNumberSubscriptionStatus {
UNPAID
}
+
// Cal AI Voice Agent
model Agent {
id String @id @default(uuid())
@@ -2552,6 +2557,9 @@ model Agent {
// Provider-specific agent ID (e.g., Retell AI agent ID)
providerAgentId String @unique
+ // Event type association for inbound agents
+ inboundEventTypeId Int?
+
enabled Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -2559,9 +2567,11 @@ model Agent {
inboundPhoneNumbers CalAiPhoneNumber[] @relation("InboundAgent")
outboundPhoneNumbers CalAiPhoneNumber[] @relation("OutboundAgent")
workflowStep WorkflowStep?
+ inboundWorkflowStep WorkflowStep? @relation("WorkflowStepInboundAgent")
@@index([userId])
@@index([teamId])
+ @@index([inboundEventTypeId])
}
model CalAiPhoneNumber {
diff --git a/packages/trpc/server/routers/viewer/aiVoiceAgent/_router.ts b/packages/trpc/server/routers/viewer/aiVoiceAgent/_router.ts
index de4536f1c2..93c5576d85 100644
--- a/packages/trpc/server/routers/viewer/aiVoiceAgent/_router.ts
+++ b/packages/trpc/server/routers/viewer/aiVoiceAgent/_router.ts
@@ -7,8 +7,10 @@ import { ZDeleteInputSchema } from "./delete.schema";
import { ZGetInputSchema } from "./get.schema";
import { ZListInputSchema } from "./list.schema";
import { ZListCallsInputSchema } from "./listCalls.schema";
+import { ZSetupInboundAgentInputSchema } from "./setupInboundAgent.schema";
import { ZTestCallInputSchema } from "./testCall.schema";
import { ZUpdateInputSchema } from "./update.schema";
+import { ZUpdateInboundAgentEventTypeInputSchema } from "./updateInboundAgentEventType.schema";
export const aiVoiceAgentRouter = router({
list: authedProcedure.input(ZListInputSchema).query(async ({ ctx, input }) => {
@@ -72,7 +74,7 @@ export const aiVoiceAgentRouter = router({
ctx,
input,
});
- }),
+ }),
createWebCall: eventOwnerProcedure.input(ZCreateWebCallInputSchema).mutation(async ({ ctx, input }) => {
const { createWebCallHandler } = await import("./createWebCall.handler");
@@ -90,4 +92,24 @@ export const aiVoiceAgentRouter = router({
ctx,
});
}),
+
+ setupInboundAgent: authedProcedure.input(ZSetupInboundAgentInputSchema).mutation(async ({ ctx, input }) => {
+ const { setupInboundAgentHandler } = await import("./setupInboundAgent.handler");
+
+ return setupInboundAgentHandler({
+ ctx,
+ input,
+ });
+ }),
+
+ updateInboundAgentEventType: eventOwnerProcedure
+ .input(ZUpdateInboundAgentEventTypeInputSchema)
+ .mutation(async ({ ctx, input }) => {
+ const { updateInboundAgentEventTypeHandler } = await import("./updateInboundAgentEventType.handler");
+
+ return updateInboundAgentEventTypeHandler({
+ ctx,
+ input,
+ });
+ }),
});
diff --git a/packages/trpc/server/routers/viewer/aiVoiceAgent/create.handler.ts b/packages/trpc/server/routers/viewer/aiVoiceAgent/create.handler.ts
index af2f1f3bb8..a1f097232e 100644
--- a/packages/trpc/server/routers/viewer/aiVoiceAgent/create.handler.ts
+++ b/packages/trpc/server/routers/viewer/aiVoiceAgent/create.handler.ts
@@ -22,7 +22,7 @@ export const createHandler = async ({ ctx, input }: CreateHandlerOptions) => {
?.generalPrompt
: undefined;
- return await aiService.createAgent({
+ return await aiService.createOutboundAgent({
name,
userId: ctx.user.id,
teamId,
diff --git a/packages/trpc/server/routers/viewer/aiVoiceAgent/setupInboundAgent.handler.ts b/packages/trpc/server/routers/viewer/aiVoiceAgent/setupInboundAgent.handler.ts
new file mode 100644
index 0000000000..f9976a367b
--- /dev/null
+++ b/packages/trpc/server/routers/viewer/aiVoiceAgent/setupInboundAgent.handler.ts
@@ -0,0 +1,53 @@
+import { createDefaultAIPhoneServiceProvider } from "@calcom/features/calAIPhone";
+import { HttpError } from "@calcom/lib/http-error";
+import logger from "@calcom/lib/logger";
+
+import { TRPCError } from "@trpc/server";
+
+import type { TrpcSessionUser } from "../../../types";
+import type { TSetupInboundAgentInputSchema } from "./setupInboundAgent.schema";
+
+type SetupInboundAgentHandlerOptions = {
+ ctx: {
+ user: NonNullable;
+ };
+ input: TSetupInboundAgentInputSchema;
+};
+
+export const setupInboundAgentHandler = async ({ ctx, input }: SetupInboundAgentHandlerOptions) => {
+ const log = logger.getSubLogger({ prefix: ["setupInboundAgentHandler"] });
+
+ try {
+ const { phoneNumber, teamId, workflowStepId } = input;
+ const userId = ctx.user.id;
+ const userTimeZone = ctx.user.timeZone || "UTC";
+
+ const aiService = createDefaultAIPhoneServiceProvider();
+
+ const createAgentResult = await aiService.createInboundAgent({
+ name: `Inbound Agent - ${phoneNumber}`,
+ phoneNumber,
+ userId,
+ teamId,
+ workflowStepId,
+ userTimeZone,
+ });
+
+ return {
+ success: true,
+ agentId: createAgentResult.id,
+ message: "Inbound agent configured successfully",
+ };
+ } catch (error) {
+ log.error("Failed to setup inbound agent", { error });
+
+ if (error instanceof HttpError) {
+ throw error;
+ }
+
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Failed to setup inbound agent",
+ });
+ }
+};
diff --git a/packages/trpc/server/routers/viewer/aiVoiceAgent/setupInboundAgent.schema.ts b/packages/trpc/server/routers/viewer/aiVoiceAgent/setupInboundAgent.schema.ts
new file mode 100644
index 0000000000..6babe71a18
--- /dev/null
+++ b/packages/trpc/server/routers/viewer/aiVoiceAgent/setupInboundAgent.schema.ts
@@ -0,0 +1,9 @@
+import { z } from "zod";
+
+export const ZSetupInboundAgentInputSchema = z.object({
+ phoneNumber: z.string(),
+ teamId: z.number().optional(),
+ workflowStepId: z.number(),
+});
+
+export type TSetupInboundAgentInputSchema = z.infer;
\ No newline at end of file
diff --git a/packages/trpc/server/routers/viewer/aiVoiceAgent/testCall.handler.ts b/packages/trpc/server/routers/viewer/aiVoiceAgent/testCall.handler.ts
index 9769e9da31..19f376eccf 100644
--- a/packages/trpc/server/routers/viewer/aiVoiceAgent/testCall.handler.ts
+++ b/packages/trpc/server/routers/viewer/aiVoiceAgent/testCall.handler.ts
@@ -2,7 +2,7 @@ import { createDefaultAIPhoneServiceProvider } from "@calcom/features/calAIPhone
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import logger from "@calcom/lib/logger";
import { PrismaAgentRepository } from "@calcom/lib/server/repository/PrismaAgentRepository";
-import { prisma } from "@calcom/prisma";
+import prisma from "@calcom/prisma";
import { TRPCError } from "@trpc/server";
@@ -27,7 +27,8 @@ export const testCallHandler = async ({ ctx, input }: TestCallHandlerOptions) =>
return;
}
- const agent = await PrismaAgentRepository.findByIdWithCallAccess({
+ const agentRepo = new PrismaAgentRepository(prisma);
+ const agent = await agentRepo.findByIdWithCallAccess({
id: input.agentId,
userId: ctx.user.id,
});
diff --git a/packages/trpc/server/routers/viewer/aiVoiceAgent/updateInboundAgentEventType.handler.ts b/packages/trpc/server/routers/viewer/aiVoiceAgent/updateInboundAgentEventType.handler.ts
new file mode 100644
index 0000000000..c24805fa52
--- /dev/null
+++ b/packages/trpc/server/routers/viewer/aiVoiceAgent/updateInboundAgentEventType.handler.ts
@@ -0,0 +1,71 @@
+import { createDefaultAIPhoneServiceProvider } from "@calcom/features/calAIPhone";
+import { replaceEventTypePlaceholders } from "@calcom/features/ee/workflows/components/agent-configuration/utils/promptUtils";
+import logger from "@calcom/lib/logger";
+import { PrismaAgentRepository } from "@calcom/lib/server/repository/PrismaAgentRepository";
+import prisma from "@calcom/prisma";
+import type { TrpcSessionUser } from "../../../types";
+import type { TUpdateInboundAgentEventTypeInputSchema } from "./updateInboundAgentEventType.schema";
+
+type UpdateInboundAgentEventTypeHandlerOptions = {
+ ctx: {
+ user: NonNullable;
+ };
+ input: TUpdateInboundAgentEventTypeInputSchema;
+};
+
+export const updateInboundAgentEventTypeHandler = async ({
+ ctx,
+ input,
+}: UpdateInboundAgentEventTypeHandlerOptions) => {
+ const log = logger.getSubLogger({ prefix: ["updateInboundAgentEventTypeHandler"] });
+
+ try {
+ const { agentId, eventTypeId, teamId } = input;
+ const userId = ctx.user.id;
+ const userTimeZone = ctx.user.timeZone || "UTC";
+
+ const aiService = createDefaultAIPhoneServiceProvider();
+
+ const agentDetails = await aiService.getAgentWithDetails({
+ id: agentId,
+ userId,
+ teamId,
+ });
+
+ if (!agentDetails.retellData.generalPrompt) {
+ throw new Error("Agent configuration not found");
+ }
+
+ // Replace placeholders in the prompt with the specific event type ID
+ const updatedPrompt = replaceEventTypePlaceholders(agentDetails.retellData.generalPrompt, eventTypeId);
+
+ // Update tools and prompt using existing updateAgentConfiguration method
+ await aiService.updateToolsFromAgentId(agentDetails.retellData.agentId, {
+ eventTypeId,
+ timeZone: userTimeZone,
+ userId,
+ teamId,
+ });
+
+ const result = await aiService.updateAgentConfiguration({
+ id: agentId,
+ userId,
+ teamId,
+ generalPrompt: updatedPrompt,
+ });
+
+ const agentRepo = new PrismaAgentRepository(prisma);
+ await agentRepo.updateEventTypeId({
+ agentId,
+ eventTypeId,
+ });
+
+ return {
+ success: true,
+ message: result.message,
+ };
+ } catch (error) {
+ log.error("Failed to update inbound agent prompt", { error });
+ throw error;
+ }
+};
diff --git a/packages/trpc/server/routers/viewer/aiVoiceAgent/updateInboundAgentEventType.schema.ts b/packages/trpc/server/routers/viewer/aiVoiceAgent/updateInboundAgentEventType.schema.ts
new file mode 100644
index 0000000000..a6960d46e8
--- /dev/null
+++ b/packages/trpc/server/routers/viewer/aiVoiceAgent/updateInboundAgentEventType.schema.ts
@@ -0,0 +1,9 @@
+import { z } from "zod";
+
+export const ZUpdateInboundAgentEventTypeInputSchema = z.object({
+ agentId: z.string(),
+ eventTypeId: z.number(),
+ teamId: z.number().optional(),
+});
+
+export type TUpdateInboundAgentEventTypeInputSchema = z.infer;
\ No newline at end of file
diff --git a/packages/trpc/server/routers/viewer/phoneNumber/list.handler.ts b/packages/trpc/server/routers/viewer/phoneNumber/list.handler.ts
index 07be9ded69..a545cbae9b 100644
--- a/packages/trpc/server/routers/viewer/phoneNumber/list.handler.ts
+++ b/packages/trpc/server/routers/viewer/phoneNumber/list.handler.ts
@@ -1,4 +1,5 @@
import { PrismaPhoneNumberRepository } from "@calcom/lib/server/repository/PrismaPhoneNumberRepository";
+import prisma from "@calcom/prisma";
import type { TrpcSessionUser } from "../../../types";
import type { TListInputSchema } from "./list.schema";
@@ -11,7 +12,8 @@ type ListHandlerOptions = {
};
export const listHandler = async ({ ctx, input }: ListHandlerOptions) => {
- return await PrismaPhoneNumberRepository.findManyWithUserAccess({
+ const phoneNumberRepo = new PrismaPhoneNumberRepository(prisma);
+ return await phoneNumberRepo.findManyWithUserAccess({
userId: ctx.user.id,
teamId: input?.teamId,
scope: input?.scope || "all",
diff --git a/packages/trpc/server/routers/viewer/workflows/delete.handler.ts b/packages/trpc/server/routers/viewer/workflows/delete.handler.ts
index 181b114d1d..94e727cbde 100644
--- a/packages/trpc/server/routers/viewer/workflows/delete.handler.ts
+++ b/packages/trpc/server/routers/viewer/workflows/delete.handler.ts
@@ -3,6 +3,7 @@ import { WorkflowRepository } from "@calcom/lib/server/repository/workflow";
import { prisma } from "@calcom/prisma";
import { WorkflowActions } from "@calcom/prisma/enums";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
+import logger from "@calcom/lib/logger";
import { TRPCError } from "@trpc/server";
@@ -22,6 +23,7 @@ type DeleteOptions = {
export const deleteHandler = async ({ ctx, input }: DeleteOptions) => {
const { id } = input;
+ const log = logger.getSubLogger({ prefix: ["workflows/deleteHandler"] });
const workflowToDelete = await prisma.workflow.findUnique({
where: {
@@ -56,6 +58,11 @@ export const deleteHandler = async ({ ctx, input }: DeleteOptions) => {
},
},
},
+ inboundAgent: {
+ select: {
+ id: true,
+ },
+ },
},
},
team: {
@@ -100,7 +107,7 @@ export const deleteHandler = async ({ ctx, input }: DeleteOptions) => {
});
}
} catch (error) {
- console.error(`Failed to handle phone number ${phoneNumber.phoneNumber}:`, error);
+ log.error(`Failed to handle phone number ${phoneNumber.phoneNumber}:`, error);
}
}
}
@@ -113,8 +120,19 @@ export const deleteHandler = async ({ ctx, input }: DeleteOptions) => {
teamId: workflowToDelete.teamId ?? undefined,
});
} catch (error) {
- console.error(`Failed to delete agent ${step.agent.id}:`, error);
- // Continue with deletion even if agent deletion fails
+ log.error(`Failed to delete agent ${step.agent.id}:`, error);
+ }
+ }
+
+ if (step.inboundAgent) {
+ try {
+ await aiPhoneService.deleteAgent({
+ id: step.inboundAgent.id,
+ userId: ctx.user.id,
+ teamId: workflowToDelete.teamId ?? undefined,
+ });
+ } catch (error) {
+ log.error(`Failed to delete inbound agent ${step.inboundAgent.id}:`, error);
}
}
}
diff --git a/packages/trpc/server/routers/viewer/workflows/update.handler.ts b/packages/trpc/server/routers/viewer/workflows/update.handler.ts
index ae36040444..f235bfc09b 100755
--- a/packages/trpc/server/routers/viewer/workflows/update.handler.ts
+++ b/packages/trpc/server/routers/viewer/workflows/update.handler.ts
@@ -440,6 +440,22 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
log.error(message);
}
+ if (oldStep.inboundAgentId) {
+ try {
+ await aiPhoneService.deleteAgent({
+ id: oldStep.inboundAgentId,
+ userId: user.id,
+ teamId: userWorkflow.teamId ?? undefined,
+ });
+ } catch (error) {
+ const message = `Failed to delete inbound agent ${
+ oldStep.inboundAgentId
+ } from external service: ${error instanceof Error ? error.message : "Unknown error"}`;
+ externalErrors.push(message);
+ log.error(message);
+ }
+ }
+
// If there were external errors, we should log them for manual cleanup
// but the operation is considered successful since DB is consistent
if (externalErrors.length > 0) {
@@ -459,6 +475,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
...newStep,
verifiedAt: oldStep.verifiedAt,
agentId: newStep.agentId || null,
+ inboundAgentId: newStep.inboundAgentId || null,
})
) {
// check if step that require team plan already existed before
diff --git a/packages/trpc/server/routers/viewer/workflows/update.schema.ts b/packages/trpc/server/routers/viewer/workflows/update.schema.ts
index bc2f317efb..fb58c61551 100644
--- a/packages/trpc/server/routers/viewer/workflows/update.schema.ts
+++ b/packages/trpc/server/routers/viewer/workflows/update.schema.ts
@@ -26,6 +26,7 @@ export const ZUpdateInputSchema = z.object({
senderName: z.string().nullable(),
includeCalendarEvent: z.boolean(),
agentId: z.string().nullish(),
+ inboundAgentId: z.string().nullish(),
})
.array(),
trigger: z.enum(WORKFLOW_TRIGGER_EVENTS),
diff --git a/packages/ui/components/icon/icon-list.mjs b/packages/ui/components/icon/icon-list.mjs
index 3b277ceeef..f7cc57d4b7 100644
--- a/packages/ui/components/icon/icon-list.mjs
+++ b/packages/ui/components/icon/icon-list.mjs
@@ -101,6 +101,7 @@ export const lucideIconList = new Set([
"pause",
"pencil",
"phone-call",
+ "phone-incoming",
"phone-outgoing",
"phone",
"play",
diff --git a/packages/ui/components/icon/icon-names.ts b/packages/ui/components/icon/icon-names.ts
index 82e916fc4e..3c8b5b6a06 100644
--- a/packages/ui/components/icon/icon-names.ts
+++ b/packages/ui/components/icon/icon-names.ts
@@ -108,6 +108,7 @@ export type IconName =
| "pause"
| "pencil"
| "phone-call"
+ | "phone-incoming"
| "phone-off"
| "phone-outgoing"
| "phone"