diff --git a/apps/builder/src/features/blocks/logic/loop/components/LoopIcon.tsx b/apps/builder/src/features/blocks/logic/loop/components/LoopIcon.tsx
new file mode 100644
index 0000000000..0710d0f74e
--- /dev/null
+++ b/apps/builder/src/features/blocks/logic/loop/components/LoopIcon.tsx
@@ -0,0 +1,17 @@
+import { featherIconsBaseProps } from "@/components/icons";
+import { Icon, type IconProps, useColorModeValue } from "@chakra-ui/react";
+import React from "react";
+
+export const LoopIcon = (props: IconProps) => (
+
+
+
+
+
+
+);
diff --git a/apps/builder/src/features/blocks/logic/loop/components/LoopNodeContent.tsx b/apps/builder/src/features/blocks/logic/loop/components/LoopNodeContent.tsx
new file mode 100644
index 0000000000..75004468e1
--- /dev/null
+++ b/apps/builder/src/features/blocks/logic/loop/components/LoopNodeContent.tsx
@@ -0,0 +1,18 @@
+import { Text } from "@chakra-ui/react";
+import { defaultLoopOptions } from "@typebot.io/blocks-logic/loop/constants";
+import type { LoopBlock } from "@typebot.io/blocks-logic/loop/schema";
+import React from "react";
+
+type Props = {
+ options?: LoopBlock["options"];
+};
+
+export const LoopNodeContent = ({ options }: Props) => {
+ const iterations = options?.iterations ?? defaultLoopOptions.iterations;
+
+ return (
+
+ {iterations ? `Repeat ${iterations} times` : "Configure..."}
+
+ );
+};
diff --git a/apps/builder/src/features/blocks/logic/loop/components/LoopSettings.tsx b/apps/builder/src/features/blocks/logic/loop/components/LoopSettings.tsx
new file mode 100644
index 0000000000..cdc33c2213
--- /dev/null
+++ b/apps/builder/src/features/blocks/logic/loop/components/LoopSettings.tsx
@@ -0,0 +1,33 @@
+import { TextInput } from "@/components/inputs";
+import { Stack } from "@chakra-ui/react";
+import { defaultLoopOptions } from "@typebot.io/blocks-logic/loop/constants";
+import type { LoopBlock } from "@typebot.io/blocks-logic/loop/schema";
+import React from "react";
+
+type Props = {
+ options: LoopBlock["options"];
+ onOptionsChange: (options: LoopBlock["options"]) => void;
+};
+
+export const LoopSettings = ({ options, onOptionsChange }: Props) => {
+ const handleIterationsChange = (iterationsStr: string) => {
+ const iterations = Number.parseInt(iterationsStr);
+ if (isNaN(iterations)) return;
+ onOptionsChange({ ...options, iterations });
+ };
+
+ return (
+
+
+
+ );
+};
diff --git a/apps/builder/src/features/editor/components/BlockIcon.tsx b/apps/builder/src/features/editor/components/BlockIcon.tsx
index bfc7c11340..e5a6362520 100644
--- a/apps/builder/src/features/editor/components/BlockIcon.tsx
+++ b/apps/builder/src/features/editor/components/BlockIcon.tsx
@@ -28,6 +28,7 @@ import { ZapierLogo } from "@/features/blocks/integrations/zapier/components/Zap
import { AbTestIcon } from "@/features/blocks/logic/abTest/components/AbTestIcon";
import { ConditionIcon } from "@/features/blocks/logic/condition/components/ConditionIcon";
import { JumpIcon } from "@/features/blocks/logic/jump/components/JumpIcon";
+import { LoopIcon } from "@/features/blocks/logic/loop/components/LoopIcon";
import { RedirectIcon } from "@/features/blocks/logic/redirect/components/RedirectIcon";
import { ReturnBlockIcon } from "@/features/blocks/logic/return/components/ReturnBlockIcon";
import { ScriptIcon } from "@/features/blocks/logic/script/components/ScriptIcon";
@@ -107,6 +108,8 @@ export const BlockIcon = ({ type, ...props }: BlockIconProps): JSX.Element => {
return ;
case LogicBlockType.RETURN:
return ;
+ case LogicBlockType.LOOP:
+ return ;
case LogicBlockType.WEBHOOK:
return ;
case IntegrationBlockType.GOOGLE_SHEETS:
diff --git a/apps/builder/src/features/editor/components/BlockLabel.tsx b/apps/builder/src/features/editor/components/BlockLabel.tsx
index 3393962e05..9d48fa4833 100644
--- a/apps/builder/src/features/editor/components/BlockLabel.tsx
+++ b/apps/builder/src/features/editor/components/BlockLabel.tsx
@@ -81,6 +81,7 @@ export const getLogicBlockLabel = (
[LogicBlockType.AB_TEST]: t("editor.sidebarBlock.abTest.label"),
[LogicBlockType.WEBHOOK]: "Webhook",
[LogicBlockType.RETURN]: "Return",
+ [LogicBlockType.LOOP]: t("editor.sidebarBlock.loop.label"),
});
export const getIntegrationBlockLabel = (
diff --git a/apps/builder/src/features/graph/components/nodes/block/BlockNodeContent.tsx b/apps/builder/src/features/graph/components/nodes/block/BlockNodeContent.tsx
index add67a25ee..bd57243a03 100644
--- a/apps/builder/src/features/graph/components/nodes/block/BlockNodeContent.tsx
+++ b/apps/builder/src/features/graph/components/nodes/block/BlockNodeContent.tsx
@@ -29,6 +29,7 @@ import { ZapierContent } from "@/features/blocks/integrations/zapier/components/
import { AbTestNodeBody } from "@/features/blocks/logic/abTest/components/AbTestNodeBody";
import { ConditionBlockContent } from "@/features/blocks/logic/condition/components/ConditionBlockContent";
import { JumpNodeBody } from "@/features/blocks/logic/jump/components/JumpNodeBody";
+import { LoopNodeContent } from "@/features/blocks/logic/loop/components/LoopNodeContent";
import { RedirectNodeContent } from "@/features/blocks/logic/redirect/components/RedirectNodeContent";
import { ReturnBlockNodeContent } from "@/features/blocks/logic/return/components/ReturnBlockNodeContent";
import { ScriptNodeContent } from "@/features/blocks/logic/script/components/ScriptNodeContent";
@@ -135,6 +136,8 @@ export const BlockNodeContent = ({
return ;
case LogicBlockType.WEBHOOK:
return ;
+ case LogicBlockType.LOOP:
+ return ;
case LogicBlockType.RETURN:
return ;
case IntegrationBlockType.GOOGLE_SHEETS: {
diff --git a/apps/builder/src/features/graph/components/nodes/block/SettingsPopoverContent.tsx b/apps/builder/src/features/graph/components/nodes/block/SettingsPopoverContent.tsx
index 928a19ff40..30f8b1aa04 100644
--- a/apps/builder/src/features/graph/components/nodes/block/SettingsPopoverContent.tsx
+++ b/apps/builder/src/features/graph/components/nodes/block/SettingsPopoverContent.tsx
@@ -23,6 +23,7 @@ import { SendEmailSettings } from "@/features/blocks/integrations/sendEmail/comp
import { ZapierSettings } from "@/features/blocks/integrations/zapier/components/ZapierSettings";
import { AbTestSettings } from "@/features/blocks/logic/abTest/components/AbTestSettings";
import { JumpSettings } from "@/features/blocks/logic/jump/components/JumpSettings";
+import { LoopSettings } from "@/features/blocks/logic/loop/components/LoopSettings";
import { RedirectSettings } from "@/features/blocks/logic/redirect/components/RedirectSettings";
import { ScriptSettings } from "@/features/blocks/logic/script/components/ScriptSettings";
import { SetVariableSettings } from "@/features/blocks/logic/setVariable/components/SetVariableSettings";
@@ -310,6 +311,11 @@ export const NodeSettings = ({
<>>
);
}
+ case LogicBlockType.LOOP: {
+ return (
+
+ );
+ }
case LogicBlockType.AB_TEST: {
return (
;
diff --git a/packages/blocks/logic/src/schema.ts b/packages/blocks/logic/src/schema.ts
index 21488af828..e908fa4029 100644
--- a/packages/blocks/logic/src/schema.ts
+++ b/packages/blocks/logic/src/schema.ts
@@ -2,6 +2,7 @@ import { z } from "@typebot.io/zod";
import { abTestBlockSchemas } from "./abTest/schema";
import { conditionBlockSchemas } from "./condition/schema";
import { jumpBlockSchema } from "./jump/schema";
+import { loopBlockSchema } from "./loop/schema";
import { redirectBlockSchema } from "./redirect/schema";
import { returnBlockSchema } from "./return/schema";
import { scriptBlockSchema } from "./script/schema";
@@ -18,6 +19,7 @@ const logicBlockSchemas = [
waitBlockSchema,
jumpBlockSchema,
returnBlockSchema,
+ loopBlockSchema,
] as const;
export const logicBlockV5Schema = z.discriminatedUnion("type", [
diff --git a/packages/bot-engine/src/blocks/logic/loop/executeLoopBlock.ts b/packages/bot-engine/src/blocks/logic/loop/executeLoopBlock.ts
new file mode 100644
index 0000000000..a60020a040
--- /dev/null
+++ b/packages/bot-engine/src/blocks/logic/loop/executeLoopBlock.ts
@@ -0,0 +1,95 @@
+import { createId } from "@paralleldrive/cuid2";
+import {
+ MAX_ITERATIONS,
+ defaultLoopOptions,
+} from "@typebot.io/blocks-logic/loop/constants";
+import type { LoopBlock } from "@typebot.io/blocks-logic/loop/schema";
+import type { SessionState } from "@typebot.io/chat-session/schemas";
+import type { SessionStore } from "@typebot.io/runtime-session-store";
+import { addVirtualEdge } from "../../../addPortalEdge";
+import type { ExecuteLogicResponse } from "../../../types";
+
+export class LoopGuardrailError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "LoopGuardrailError";
+ }
+}
+
+export const executeLoopBlock = (
+ block: LoopBlock,
+ { state, sessionStore }: { state: SessionState; sessionStore: SessionStore },
+): ExecuteLogicResponse => {
+ const loopOptions = block.options ?? defaultLoopOptions;
+ const { loopConfiguration, currentBlockId } = state;
+
+ // Get the loop index and total iterations
+ const existingConfiguration = loopConfiguration?.[block.id];
+ const loopIndex = existingConfiguration?.index ?? 0;
+ const totalIterations =
+ loopOptions.iterations ?? defaultLoopOptions.iterations;
+
+ // Check if we've exceeded the maximum iterations guard rail
+ if (totalIterations > MAX_ITERATIONS) {
+ return {
+ outgoingEdgeId: null,
+ logs: [
+ {
+ status: "error",
+ description: `Loop iterations (${totalIterations}) exceeds maximum allowed (${MAX_ITERATIONS})`,
+ },
+ ],
+ };
+ }
+
+ // Store the loop index in a variable if this is the first iteration
+ if (!existingConfiguration) {
+ sessionStore.setVariable("loopIndex", 0);
+ } else {
+ sessionStore.setVariable("loopIndex", loopIndex);
+ }
+
+ // Check if we've completed all iterations
+ if (loopIndex >= totalIterations) {
+ // Reset loop configuration for this block
+ const { [block.id]: _, ...restLoopConfig } = state.loopConfiguration ?? {};
+ const newState: SessionState = {
+ ...state,
+ loopConfiguration: restLoopConfig,
+ };
+
+ // Exit the loop
+ return {
+ outgoingEdgeId: block.outgoingEdgeId,
+ newSessionState: newState,
+ };
+ } // Create a virtual edge to create a loop
+
+ // Find the group ID of the current block
+ const currentGroup = state.typebotsQueue[0].typebot.groups.find((group) =>
+ group.blocks.some((b) => b.id === currentBlockId),
+ );
+
+ const groupId = currentGroup?.id ?? block.id;
+
+ const { edgeId, newSessionState } = addVirtualEdge(state, {
+ to: { groupId, blockId: currentBlockId ?? block.id },
+ });
+
+ // Increment the loop index for the next iteration
+ const updatedSessionState: SessionState = {
+ ...newSessionState,
+ loopConfiguration: {
+ ...newSessionState.loopConfiguration,
+ [block.id]: {
+ index: loopIndex + 1,
+ },
+ },
+ };
+
+ // Continue the loop
+ return {
+ outgoingEdgeId: edgeId,
+ newSessionState: updatedSessionState,
+ };
+};
diff --git a/packages/bot-engine/src/executeLogic.ts b/packages/bot-engine/src/executeLogic.ts
index 09091ae7d2..d8fb13d155 100644
--- a/packages/bot-engine/src/executeLogic.ts
+++ b/packages/bot-engine/src/executeLogic.ts
@@ -6,6 +6,7 @@ import type { SetVariableHistoryItem } from "@typebot.io/variables/schemas";
import { executeAbTest } from "./blocks/logic/abTest/executeAbTest";
import { executeConditionBlock } from "./blocks/logic/condition/executeConditionBlock";
import { executeJumpBlock } from "./blocks/logic/jump/executeJumpBlock";
+import { executeLoopBlock } from "./blocks/logic/loop/executeLoopBlock";
import { executeRedirect } from "./blocks/logic/redirect/executeRedirect";
import { executeReturnBlock } from "./blocks/logic/return/executeReturnBlock";
import { executeScript } from "./blocks/logic/script/executeScript";
@@ -51,5 +52,7 @@ export const executeLogic = async ({
return executeWebhookBlock(block);
case LogicBlockType.RETURN:
return executeReturnBlock(state);
+ case LogicBlockType.LOOP:
+ return executeLoopBlock(block, { state, sessionStore });
}
};
diff --git a/packages/bot-engine/src/resetSessionState.ts b/packages/bot-engine/src/resetSessionState.ts
index 153574d596..9a805eec21 100644
--- a/packages/bot-engine/src/resetSessionState.ts
+++ b/packages/bot-engine/src/resetSessionState.ts
@@ -6,6 +6,7 @@ export const resetSessionState = (state: SessionState): SessionState => ({
currentVisitedEdgeIndex: undefined,
previewMetadata: undefined,
progressMetadata: undefined,
+ loopConfiguration: undefined,
typebotsQueue: state.typebotsQueue.map((queueItem) => ({
...queueItem,
answers: [],
diff --git a/packages/chat-session/src/schemas.ts b/packages/chat-session/src/schemas.ts
index e8c9d868ec..81dd07fa54 100644
--- a/packages/chat-session/src/schemas.ts
+++ b/packages/chat-session/src/schemas.ts
@@ -142,6 +142,13 @@ const sessionStateSchemaV3 = sessionStateSchemaV2
setVariableIdsForHistory: z.array(z.string()).optional(),
currentSetVariableHistoryIndex: z.number().optional(),
workspaceId: z.string(),
+ loopConfiguration: z
+ .record(
+ z.object({
+ index: z.number(),
+ }),
+ )
+ .optional(),
previewMetadata: z
.object({
answers: z.array(answerSchema).optional(),
diff --git a/packages/runtime-session-store/src/index.ts b/packages/runtime-session-store/src/index.ts
index 27b1e7714e..a911fa28b2 100644
--- a/packages/runtime-session-store/src/index.ts
+++ b/packages/runtime-session-store/src/index.ts
@@ -8,12 +8,14 @@ export class SessionStore {
private emailSendingCount: number;
private prevHash: string | undefined;
private createdAt: Date;
+ private variableStore: Record;
constructor() {
this.isolate = undefined;
this.emailSendingCount = 0;
this.prevHash = undefined;
this.createdAt = new Date();
+ this.variableStore = {};
}
getEmailSendingCount(): number {
@@ -47,6 +49,14 @@ export class SessionStore {
getCreatedAt(): Date {
return this.createdAt;
}
+
+ setVariable(name: string, value: any): void {
+ this.variableStore[name] = value;
+ }
+
+ getVariable(name: string): any {
+ return this.variableStore[name];
+ }
}
export const sessionStores = new Map();