Conversational Flows
Build structured conversation paths for lead capture, booking, and multi-step processes
Conversational Flows
Flows are deterministic conversation paths that guide users through structured interactions. While the AI agent handles free-form questions, flows ensure consistent data collection and multi-step processes.
What are Flows?
A Flow is a series of steps that execute in sequence when triggered. Each step can:
- Send a message
- Collect and validate user input
- Branch based on conditions
- Call webhook tools
- Generate AI responses
- Transfer to a human agent
Code[Trigger] → [Welcome] → [Collect Name] → [Collect Email] → [Confirm] → [End]
When to Use Flows
| Scenario | Use Flow | Use Free-form LLM |
|---|---|---|
| Collecting required data (name, email, phone) | Yes | No |
| Compliance-required messaging | Yes | No |
| Appointment booking | Yes | Maybe |
| General Q&A | No | Yes |
| Product recommendations | No | Yes |
| Order status lookup | Maybe | Yes |
Flow Triggers
Three trigger types run today:
| Trigger Type | Description | Example |
|---|---|---|
keyword | Message contains any of the words, case-insensitive substring match | "book", "appointment", "schedule" |
intent | Message expresses the intent you described, judged by a classifier rather than by matching words | "wants a price or a quote" |
always | Every inbound message that is not already inside a flow | Welcome/onboarding flow |
always restarts once the flow completes, so the contact's next message begins
it again. It is not once per conversation.
When a flow says nothing
A flow that ends on the message that started it, without replying, hands that message to the agent, and the agent answers it. A flow that only decides it has nothing to do does not leave the contact unanswered.
Three things are silence on purpose, and stay silent:
- A
transferstep, even with an emptymessage. This is how you keep the agent quiet while a person handles a contact. - An
llmstep that chose not to reply. - A flow that already spoke. Once a flow has sent the contact something, the exchange is the flow's: if it later ends on a quiet step, the agent does not step in on that message.
Intent triggers
Write trigger.intent as one plain sentence describing what the contact wants:
"wants a price or a quote", "asks where their order is". Any language works, and
the contact does not have to write in the same one. A single word such as
"sales" is accepted, but a sentence leaves less to guess.
JSON{ "type": "intent", "intent": "asks where their order is" }
It is read for meaning, not for words. "I don't want pricing info, stop writing
to me" contains the word and does not start a pricing flow. A keyword trigger
on "pricing" would.
What decides which flow starts:
- Priority still comes first. A
keywordoralwaysflow with a higherprioritywins without the intent being considered. Analwaysflow above your intent flows means they never start; give intent flows the higher number. - An intent flow starts only on a clear match. When no intent matches, the
next
keywordoralwaysflow in priority order starts. If there is none, the agent answers. - At most 12 intent flows are considered per message, the 12 with the highest priority. An intent longer than 300 characters is cut at 300.
- If the classifier is unavailable, the message is handled as if no intent matched. The contact still gets an answer; the intent flow does not start.
- Reading the intent adds time before the reply, typically under a second, and only on messages that no higher-priority trigger claimed. Agents without intent flows are unaffected.
A bare "STOP" or an order number carries no intent to read. Use a keyword
trigger for exact words.
Step Types
Every step has the same envelope. config is required, and its fields depend on
type:
JSON{ "id": "<your-step-id>", "type": "message | collect | condition | tool | llm | transfer", "config": { }, "nextStepId": "<id of the next step, or omit>" }
That is the envelope, not a runnable step: config is empty because what goes
in it depends on type. Each section below gives the real shape.
Steps run in array order unless a step names nextStepId, or a condition
sends the run elsewhere. A step with no nextStepId that is last in the array
completes the flow.
Message Step
Send a fixed message. {{variables}} are replaced with what earlier steps
collected.
JSON{ "id": "welcome", "type": "message", "config": { "text": "Welcome to Acme! How can I help you today?" }, "nextStepId": "ask_email" }
Collect Step
Ask for something and wait for the customer's next message.
JSON{ "id": "ask_email", "type": "collect", "config": { "variable": "customer_email", "prompt": "What's your email address?", "validation": { "type": "email" }, "errorMessage": "That does not look like an email address. Try again." }, "nextStepId": "ask_size" }
errorMessage sits beside validation, not inside it.
Validation types
type | Extra fields | Accepts |
|---|---|---|
text | minLength, maxLength | any text |
email | a valid address | |
phone | a valid phone number | |
number | min, max | numeric input |
date | accepts anything, see below | |
choice | options (required) | one of options |
date currently accepts whatever the contact typed, including tomorrow and
asdf, and minDate / maxDate are not read. Validate a date in your own
handler, or constrain it with choice.
choice is the reason to reach for a flow at all: the answer is constrained to
your list, so what reaches your systems is one of a known set rather than
whatever the customer typed.
JSON{ "id": "ask_size", "type": "collect", "config": { "variable": "size", "prompt": "How many people are on your team? Reply 1-10, 11-50, 51-200, or 200+.", "validation": { "type": "choice", "options": ["1-10", "11-50", "51-200", "200+"] }, "errorMessage": "Please pick one of: 1-10, 11-50, 51-200, 200+." }, "nextStepId": "route" }
Condition Step
Branch. if is interpolated first, then compared, so "{{budget}} > 10000"
becomes "25000 > 10000".
JSON{ "id": "route", "type": "condition", "config": { "conditions": [ { "if": "{{size}} == 200+", "goto": "enterprise_path" } ], "defaultGoto": "standard_path" } }
Supported operators are ==, !=, > and <. There is no AND, no OR and
no parentheses. For anything more, chain several condition steps or move the
decision into a tool.
defaultGoto runs when no condition matches. Without it, and with nothing
matching, the flow completes. If it has said nothing so far, the agent answers
the message instead (when a flow says nothing).
A condition cannot be the first step unless it has a defaultGoto: creating
or updating a flow that way returns 400 invalid_request. Nothing has been
collected at the first step, so its conditions come out the same on every
message.
{{ }} reads only variables collected earlier in the same flow, by a collect
step or a storeResultAs. Contact fields and metadata are not available:
{{contact.plan}} is not replaced, and a condition written on it never matches.
A name is letters, digits and underscores, with no dots, so {{lead.status}}
does not reach inside a stored object either. To branch on something you hold
about the contact, look it up in a tool step whose result is a plain value (a
string, a number or a boolean) and compare that.
Tool Step
Call one of the agent's tools.
JSON{ "id": "save_lead", "type": "tool", "config": { "toolName": "create_lead", "params": { "email": "{{customer_email}}", "size": "{{size}}", "source": "whatsapp" }, "storeResultAs": "lead" }, "nextStepId": "confirm" }
params values wrapped in {{ }} are replaced with collected data; anything
else is passed through literally. (action appears in older examples and is
accepted, but nothing reads it, so it is left out here.) params may be omitted for a tool that takes
none. The tool must already exist on the agent before the flow runs.
LLM Step
Let the model answer, inside the flow.
JSON{ "id": "suggest", "type": "llm", "config": { "systemPrompt": "Suggest three products for a team of {{size}}. Be brief.", "collectVariable": "recommendations" }, "nextStepId": "confirm" }
Transfer Step
End the flow and hand off to a human.
JSON{ "id": "escalate", "type": "transfer", "config": { "message": "I'm connecting you with a specialist. Please hold." } }
This sends message, marks the session transferred, and silences the agent
for that contact. Their next messages are stored and appear in your inbox, and
the agent does not answer them: a contact who asked for a person is not talked
over by the bot while they wait.
The agent takes the thread back as soon as someone answers. Any outbound message you send to that contact, from the inbox or the API, ends the handoff. There is no button to press and no state to clean up.
Variables
{{name}} in any string is replaced with what an earlier step collected. A
variable that was never collected renders as the literal {{name}} in the
message the customer receives, so check that every one is populated before it is
used.
Variables come from three places:
| Step type | Field that creates the variable |
|---|---|
collect | config.variable |
tool | config.storeResultAs |
llm | config.collectVariable |
Via Dashboard
Navigate to Flows
Go to Senders > select your sender > Agent tab > Flows section.
Create New Flow
Click Create Flow and enter:
- Name: A descriptive name (e.g., "Lead Capture Flow")
- Description: What this flow does
- Trigger: How the flow is activated
Add Steps
Use the visual flow builder to add steps:
- Click Add Step
- Select the step type
- Configure the step parameters
- Connect steps by dragging between nodes
Configure Triggers
Set up how the flow is triggered:
- For keyword triggers, enter the words that activate the flow
- For intent triggers, describe in one sentence what the contact wants
- For always, the flow runs on every inbound message that is not already inside a flow
Test the Flow
Use the Test button to simulate a conversation and verify each step works correctly.
Activate Flow
Toggle the flow to Active to enable it for incoming messages.
Via API
Create Flow
import Zavudev from "@zavudev/sdk";
const zavu = new Zavudev({ apiKey: process.env["ZAVUDEV_API_KEY"] });
const flow = await zavu.senders.agent.flows.create("sender_abc123", {
name: "Lead Capture",
description: "Collect lead information from new contacts",
trigger: {
type: "keyword",
keywords: ["interested", "info", "learn more"],
},
steps: [
{
id: "welcome",
type: "message",
config: { text: "Great! I'd love to help. Let me collect some information." },
nextStepId: "get_name",
},
{
id: "get_name",
type: "collect",
config: { variable: "customer_name", prompt: "What's your name?" },
nextStepId: "get_email",
},
{
id: "get_email",
type: "collect",
config: {
variable: "customer_email",
prompt: "What's your email address?",
validation: { type: "email" },
errorMessage: "That does not look like an email address. Try again.",
},
nextStepId: "confirm",
},
{
id: "confirm",
type: "message",
config: {
text: "Thanks {{customer_name}}! Our team will contact you at {{customer_email}} within 24 hours.",
},
},
],
});
console.log("Flow created:", flow.id);
List Flows
const flows = await zavu.senders.agent.flows.list("sender_abc123");
for (const flow of flows.items) {
console.log(`${flow.name} (${flow.id}) - ${flow.enabled ? "Active" : "Inactive"}`);
}
Update Flow
const flow = await zavu.senders.agent.flows.update(
"flow_xyz789",
{
senderId: "sender_abc123",
name: "Updated Lead Capture",
enabled: true,
}
);
Example Flows
Lead Capture Flow
Qualify an inbound lead into three known fields before anyone reads the thread.
JSON{ "name": "Lead Capture", "trigger": { "type": "keyword", "keywords": ["pricing", "demo", "info"] }, "enabled": true, "priority": 10, "steps": [ { "id": "intro", "type": "message", "config": { "text": "Thanks for your interest! Let me get some details." }, "nextStepId": "get_name" }, { "id": "get_name", "type": "collect", "config": { "variable": "name", "prompt": "What's your name?" }, "nextStepId": "get_email" }, { "id": "get_email", "type": "collect", "config": { "variable": "email", "prompt": "What's your email?", "validation": { "type": "email" }, "errorMessage": "That does not look like an email address. Try again." }, "nextStepId": "get_size" }, { "id": "get_size", "type": "collect", "config": { "variable": "size", "prompt": "How many employees? Reply 1-10, 11-50, 51-200 or 200+.", "validation": { "type": "choice", "options": ["1-10", "11-50", "51-200", "200+"] }, "errorMessage": "Please reply with 1-10, 11-50, 51-200 or 200+." }, "nextStepId": "save_lead" }, { "id": "save_lead", "type": "tool", "config": { "toolName": "create_lead", "params": { "name": "{{name}}", "email": "{{email}}", "companySize": "{{size}}" } }, "nextStepId": "confirm" }, { "id": "confirm", "type": "message", "config": { "text": "Thanks {{name}}! A team member will reach out within 24 hours." } } ] }
The choice step is what makes this worth a flow: size reaching your CRM is
always one of four values, not whatever the customer typed.
Appointment Booking Flow
JSON{ "name": "Appointment Booking", "trigger": { "type": "keyword", "keywords": ["book", "appointment", "schedule"] }, "enabled": true, "priority": 10, "steps": [ { "id": "intro", "type": "message", "config": { "text": "I'd be happy to help you book an appointment!" }, "nextStepId": "get_service" }, { "id": "get_service", "type": "collect", "config": { "variable": "service_type", "prompt": "What type of appointment? Reply consultation, follow-up or review.", "validation": { "type": "choice", "options": ["consultation", "follow-up", "review"] }, "errorMessage": "Please reply with consultation, follow-up or review." }, "nextStepId": "get_date" }, { "id": "get_date", "type": "collect", "config": { "variable": "preferred_date", "prompt": "What date works best?", "validation": { "type": "date" } }, "nextStepId": "check_slots" }, { "id": "check_slots", "type": "tool", "config": { "toolName": "get_available_slots", "params": { "date": "{{preferred_date}}", "service": "{{service_type}}" }, "storeResultAs": "slots" }, "nextStepId": "show_slots" }, { "id": "show_slots", "type": "message", "config": { "text": "Available times on {{preferred_date}}: {{slots}}" }, "nextStepId": "get_time" }, { "id": "get_time", "type": "collect", "config": { "variable": "selected_time", "prompt": "Which time works for you?" }, "nextStepId": "book" }, { "id": "book", "type": "tool", "config": { "toolName": "create_appointment", "params": { "service": "{{service_type}}", "date": "{{preferred_date}}", "time": "{{selected_time}}" } }, "nextStepId": "confirm" }, { "id": "confirm", "type": "message", "config": { "text": "Your {{service_type}} appointment is booked for {{preferred_date}} at {{selected_time}}. See you then!" } } ] }
Both toolName values above must match tools that already exist: create_lead,
get_available_slots and create_appointment must already exist on the agent.
Flow Sessions
When a user enters a flow, a session is created to track their progress. Sessions store:
- Current step
- Collected variables
- Timestamps
- Channel information
Best Practices
Aim for 5-7 steps maximum. Long flows have higher abandonment rates.
Validate critical inputs (email, phone) immediately after collection.
Let users say "stop" or "cancel" to exit the flow at any time.
Reference collected data in messages to personalize the experience.