# 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

```
[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 |

> **Tip:**
> Use flows when you need **guaranteed outcomes**. Use the LLM when you need **flexibility**.

## 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 [`transfer` step](#transfer-step), even with an empty `message`. This is how
  you keep the agent quiet while a person handles a contact.
- An `llm` step 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.

> **Warning:**
> An `always` flow that replies claims every message, so the agent behind it never
> answers. That is the right shape for a bot that is only a flow, and the wrong
> one for a gate in front of an agent.

### 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 `keyword` or `always` flow with a higher
  `priority` wins without the intent being considered. An `always` flow 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 `keyword` or `always` flow 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.

> **Warning:**
> `manual` is accepted by the API and stored on the flow, and nothing starts it: a
> flow created with it is never triggered and never says so. Use `keyword`,
> `intent` or `always`.

## 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](#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"
}
```

> **Note:**
> Use `config.toolName`. `config.toolId` is the original spelling and still works:
> either field is accepted, and either one may hold the tool's name or its id.
>
> A step pointing at a tool the agent does not have used to reply
> `Tool "tool_9f2a..." not found` to the customer as an ordinary message. It now
> ends the session and logs the misconfiguration instead. If the flow had not said
> anything yet, the plain agent answers that message; otherwise it takes the
> conversation from the next one.
>
> The session ends as **abandoned**, not completed: the flow never reached the end
> you designed. Check that status if your completion numbers look better than the
> conversations do.

`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.

> **Warning:**
> **Nobody is notified for you.** There is no queue and no assignment: the
> conversation waits in the inbox until a person opens it. If your team does not
> watch the inbox, put a `tool` step before the `transfer` and notify yourself
> from your own systems.
>
> `notifyWebhook` and `reason` are accepted on the config and never read.

## 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:
    1. Click **Add Step**
    2. Select the step type
    3. Configure the step parameters
    4. 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

```typescript
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);
```

```bash
curl -X POST https://api.zavu.dev/v1/senders/sender_abc123/agent/flows \
  -H "Authorization: Bearer $ZAVUDEV_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "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 would love to help. Let me collect some information." },
        "nextStepId": "get_name"
      },
      {
        "id": "get_name",
        "type": "collect",
        "config": { "variable": "customer_name", "prompt": "What is your name?" },
        "nextStepId": "get_email"
      },
      {
        "id": "get_email",
        "type": "collect",
        "config": {
          "variable": "customer_email",
          "prompt": "What is 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."
        }
      }
    ]
  }'
```

> **Note:**
> A flow is created **disabled** unless the request sends `"enabled": true`, so the examples above create a flow that does not run yet. Send `enabled: true` when creating it, or turn it on with [Update Flow](#update-flow).

### List Flows

```typescript
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"}`);
}
```

```bash
curl https://api.zavu.dev/v1/senders/sender_abc123/agent/flows \
  -H "Authorization: Bearer $ZAVUDEV_API_KEY"
```

### Update Flow

```typescript
const flow = await zavu.senders.agent.flows.update(
  "flow_xyz789",
  {
    senderId: "sender_abc123",
    name: "Updated Lead Capture",
    enabled: true,
  }
);
```

```bash
curl -X PATCH https://api.zavu.dev/v1/senders/sender_abc123/agent/flows/flow_xyz789 \
  -H "Authorization: Bearer $ZAVUDEV_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"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

> **Warning:**
> **Sessions do not expire.** A contact who stops answering halfway keeps an open
> session, and their next message resumes that flow at the step they left, weeks
> later, with the variables they had. It is not restarted, and there is no
> timeout.
>
> Plan for it: give a long flow a way out, and do not assume a `collect` step is
> answered by the message that follows it in the same hour. A flow that ends in a
> `transfer` or completes cleanly closes its session, which is the reliable way
> to stop one.
>
> There is also no API for reading sessions. The status is visible in the
> dashboard.

## Best Practices

  - **Keep Flows Short**: Aim for 5-7 steps maximum. Long flows have higher abandonment rates.
  - **Validate Early**: Validate critical inputs (email, phone) immediately after collection.
  - **Provide Exit Points**: Let users say "stop" or "cancel" to exit the flow at any time.
  - **Use Variables**: Reference collected data in messages to personalize the experience.

## Next Steps

  - [Add Tools](https://www.zavu.dev/docs/guides/ai-agents/tools): Enable your flows to execute actions via webhooks
  - [Knowledge Base](https://www.zavu.dev/docs/guides/ai-agents/knowledge-base): Let your agent answer questions from documents
