> ## Documentation Index
> Fetch the complete documentation index at: https://docs.syllable.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Reference

> Complete technical reference for Step Workflow configuration options, lifecycle hooks, and actions

This reference documents all configuration options for Step Workflows. For an introduction to what Step Workflows are and when to use them, see the [Overview](./overview).

The sections below are ordered the way you usually need them when authoring a workflow:

1. **[Workflow Configuration](#workflow-configuration)** — the shape of a workflow, including activation mode and composing multiple workflows in one agent.
2. **[Step Configuration](#step-configuration)** — the shape of a single step.
3. **[Inputs Schema](#inputs-schema)** — declaring and reading parameters.
4. **[Step Transitions](#step-transitions)** — routing between steps.
5. **[Tool Configuration](#tool-configuration)** — controlling tool visibility.
6. **[Lifecycle Hooks](#lifecycle-hooks)** — what fires when.
7. **[Runtime Lifecycle](#runtime-lifecycle)** — how a submission unfolds at runtime, plus the latency and bridge-step rules.
8. **[Actions Reference](#actions-reference)** — every action and its parameters.
9. **[Expressions](#expressions)** — JMESPath and CEL.
10. **[Templates](#templates)** — variable substitution in strings.
11. **[Variables](#variables)** — scopes, naming, and the underlying registry.

***

## Workflow Configuration

A workflow is one `StepsTask` — an ordered list of steps with its own state, lifecycle events, and submit tool. Each `StepsTask` accepts these workflow-level fields:

| Property    | Purpose                                                                                 | Example                          |
| ----------- | --------------------------------------------------------------------------------------- | -------------------------------- |
| `id`        | Unique workflow identifier (used for state isolation and diagnostics)                   | `"patient_lookup"`               |
| `tool.name` | Submit tool name exposed to the agent                                                   | `"submit_patient_lookup"`        |
| `start`     | When the workflow's activation lifecycle runs — see [Activation Mode](#activation-mode) | `"auto"` (default) or `"manual"` |
| `steps`     | Ordered list of steps — see [Step Configuration](#step-configuration)                   | `[{...}, {...}]`                 |

### Activation Mode

`start` controls whether the workflow's visible activation lifecycle (`on.start`, initial `on.enter`, and the synthetic submit-tool call that delivers step 1's instructions) fires at session start or defers until something invokes the workflow:

| Value              | Behavior                                                                                                                                                                                                                              |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `"auto"` (default) | Activates at session start. Workflow state initializes, `on.start` fires, and the agent receives step 1's instructions before the first user turn. Existing single-workflow tools all behave this way.                                |
| `"manual"`         | Defers activation until first invocation. The submit tool is still visible to the agent from session start, but `on.start` does not fire and step 1's instructions are not delivered until the workflow is called for the first time. |

Use `start: "manual"` when a workflow is conceptually a sub-routine — for example, "look up patient record" — that should only run when another workflow needs it. Activating at session start would only confuse the agent with irrelevant submit-tool exposure or fire `on.start` actions out of context.

A manual workflow activates on its first invocation, regardless of how it's reached:

* **`call` action from another workflow with all step-1 required arguments** — the secondary activates, submits step 1 synthetically, and may transition to step 2 in the same turn.
* **`call` action with no arguments, or arguments that don't cover step 1's required inputs** — the secondary activates and returns step 1's instructions; the step pointer stays on step 1 and no inputs are recorded. The agent then submits step 1 normally on a later turn.
* **Direct LLM submit-tool call** — same as the partial-args case above. The agent can "wake" the workflow with a bare submit, and then collect inputs across subsequent turns.

After activation, a manual workflow behaves exactly like an `auto` workflow: later submit calls validate and advance per the normal step contract.

### Composing Multiple Workflows

A single tool definition can declare more than one workflow by passing an array to `task` instead of a single object. Each workflow runs as an independent state machine with its own submit tool; they do not share variables, current-step pointers, or completion flags.

```json theme={null}
{
  "type": "context",
  "context": {
    "task": [
      {
        "type": "steps",
        "id": "triage",
        "start": "auto",
        "tool": { "name": "submit_triage" },
        "steps": [
          {
            "id": "ASK_REASON",
            "inputs": [{ "name": "patient_id", "required": true }],
            "on": {
              "submit": [
                {
                  "action": "call",
                  "name": "submit_patient_lookup",
                  "arguments": { "patient_id": "{{inputs.patient_id}}" }
                }
              ]
            },
            "next": ["SUMMARIZE"]
          }
        ]
      },
      {
        "type": "steps",
        "id": "patient_lookup",
        "start": "manual",
        "tool": { "name": "submit_patient_lookup" },
        "steps": [
          { "id": "LOOKUP", "inputs": [{ "name": "patient_id", "required": true }] }
        ]
      }
    ]
  }
}
```

In this **primary + secondary** pattern, the auto-starting `triage` workflow drives the conversation. When `ASK_REASON` submits, its `on.submit` `call` action invokes `submit_patient_lookup` with the collected `patient_id`, which activates the manual `patient_lookup` workflow and submits its step 1 in one turn.

Two further notes when running multiple workflows in one agent:

* **Submit tool names must be unique.** Each workflow's submit tool defaults to `submit_inputs` if `tool.name` is not set; collisions across workflows produce duplicate schemas and undefined behavior. Always set `tool.name` explicitly when you have more than one workflow.
* **`tools.allow` is union-filtered across workflows.** Each step's `tools.allow` (see [Tool Configuration](#tool-configuration)) restricts what the agent sees only when *every* active workflow's current step has an allow-list set; if any active workflow's current step is unrestricted, no filtering applies. The most-permissive workflow wins.

***

## Step Configuration

Each step in a workflow is defined with the following properties:

| Property       | Purpose                                                                   | Example                                       |
| -------------- | ------------------------------------------------------------------------- | --------------------------------------------- |
| `id`           | Unique step identifier                                                    | `"COLLECT_NAME"`                              |
| `goal`         | Brief description (shown to the agent)                                    | `"Collect the user's name"`                   |
| `instructions` | Agent guidance (array of strings)                                         | `["Ask for the user's full name."]`           |
| `inputs`       | Parameters to collect (JSON Schema) — see [Inputs Schema](#inputs-schema) | `[{name: "user_name", type: "string"}]`       |
| `on`           | Lifecycle hooks for actions — see [Lifecycle Hooks](#lifecycle-hooks)     | `{start: [...], enter: [...], submit: [...]}` |
| `next`         | Transition routing — see [Step Transitions](#step-transitions)            | `["NEXT_STEP"]` or `[{if: "...", id: "..."}]` |
| `tools`        | Tool visibility control — see [Tool Configuration](#tool-configuration)   | `{allow: [...], call: true}`                  |

### id

A unique string identifier for the step. Used in:

* Transition routing (`next` entries)
* Manual navigation (`go_to_step`)
* Debugging and logging

**Best practice:** Use SCREAMING\_SNAKE\_CASE for step IDs (e.g., `COLLECT_EMAIL`, `VERIFY_DOB`).

### goal

A brief description of what this step accomplishes. This text becomes the submit tool's description, helping the agent understand the purpose of the current step.

```json theme={null}
{
  "id": "COLLECT_NAME",
  "goal": "Collect the user's full name for the contact form"
}
```

### instructions

Guidance for the agent on how to execute this step. The current implementation expects an array of instruction strings, even for a single line:

```json theme={null}
{
  "instructions": [
    "Ask the user for their full name. Be polite and professional."
  ]
}

{
  "instructions": [
    "Ask the user for their full name.",
    "If they only provide a first name, ask for their last name as well.",
    "Confirm the spelling if the name is unusual."
  ]
}
```

Instructions support template substitution for dynamic content (see [Templates](#templates)):

```json theme={null}
{
  "instructions": [
    "Welcome back, {{user_name}}! Let's continue where we left off."
  ]
}
```

***

## Inputs Schema

Each step can define `inputs` — parameters the agent should collect before advancing. The `inputs` array becomes the submit tool's function schema.

```json theme={null}
{
  "inputs": [
    {
      "name": "first_name",
      "type": "string",
      "description": "The user's first name",
      "required": true
    },
    {
      "name": "date_of_birth",
      "type": "string",
      "format": "date",
      "description": "Date of birth (YYYY-MM-DD)",
      "required": true
    },
    {
      "name": "preferred_language",
      "type": "string",
      "enum": ["English", "Spanish", "French"],
      "description": "Preferred language for communication",
      "required": false
    }
  ]
}
```

### Available Fields

| Field         | Type       | Required | Description                                                                                         |
| ------------- | ---------- | -------- | --------------------------------------------------------------------------------------------------- |
| `name`        | string     | Yes      | Parameter name                                                                                      |
| `type`        | string     | No       | JSON Schema type: `string`, `number`, `integer`, `boolean`, `object`, `array`. Defaults to `string` |
| `description` | string     | No       | Human-readable description shown to agent                                                           |
| `required`    | boolean    | No       | Whether parameter must be collected. Defaults to `true`                                             |
| `enum`        | list\[str] | No       | Allowed values (agent constrained to these options)                                                 |
| `format`      | string     | No       | Format hint: `date`, `time`, `date-time`, `email`, `uri`, etc.                                      |
| `pattern`     | string     | No       | Regex pattern for validation                                                                        |

### Accumulation Behavior

Inputs accumulate across multiple submissions until all required fields are collected:

```
Turn 1: Agent submits (first_name="Alice")
        → Validation fails: missing date_of_birth
        → inputs.first_name = "Alice" (retained)

Turn 2: Agent submits (date_of_birth="1990-05-15")
        → Validation passes: all required fields present
        → Step advances successfully
```

**Key behaviors:**

* **New values overwrite**: if the agent provides a new value for an existing input, it replaces the old value.
* **Missing values retained**: inputs not included in a call keep their previous values.
* **Empty string = missing**: for string types, `""` and whitespace-only strings are treated as empty.
* **Cleared on transition**: `inputs.*` is cleared when the workflow transitions to a different step. To keep a value beyond the current step, write it to a global or `local.*` variable with `save` or `set` (see [Variables](#variables)).

### Referencing inputs in expressions and templates

<Warning>
  **Reference step inputs with the `inputs.` prefix, or copy them into globals first with `save`.** An input declared here as `{"name": "foo"}` lives at `inputs.foo` — the bare name `foo` resolves against global scope, not step inputs.

  You have two options for reading an input in conditions, transitions, or templates:

  **Option 1 — use `inputs.foo` directly.** Works anywhere the current step's inputs are in scope (`if`, `next[].if`, `valueFrom`, `{{...}}` templates in `say`/`call`/`instructions`). Remember that `inputs.*` is cleared the moment the workflow transitions to another step.

  ```json theme={null}
  {"if": "inputs.foo == `true`", "id": "YES"}
  {"if": "is_true(inputs.foo)", "id": "YES"}
  ```

  **Option 2 — `save` the input into a global variable, then reference it by its bare name.** Use this when the value needs to survive beyond the current step (later steps, final outputs, tool arguments after transition). `{"action": "save"}` in `on.submit` copies every input to a global of the same name; after that, `{{foo}}` and `"if": "foo == \`true\`"\` both resolve.

  ```json theme={null}
  {
    "on": {"submit": [{"action": "save", "inputs": ["foo"]}]},
    "next": [
      // Same step: either form works, but `inputs.foo` is clearer about where it comes from.
      {"if": "inputs.foo == `true`", "id": "YES"}
    ]
  }
  // In a *later* step, only the saved global is available:
  {"if": "foo == `true`", "id": "YES"}
  ```

  **The silent-failure trap:** writing `foo == \`true\``when you meant`inputs.foo == \`true\`\` does *not* raise an error — the bare name is valid JMESPath, it just resolves to nothing in global scope, so the condition evaluates to false and the branch never fires. If a transition appears to "not work," this is the first thing to check. See [Variable Scopes](#variable-scopes) for the full scope list.
</Warning>

***

## Step Transitions

The `next` field controls workflow routing after a step is successfully submitted.

### Transition Basics

```json theme={null}
// Simple unconditional transition
"next": ["NEXT_STEP"]
"next": [{"id": "NEXT_STEP"}]

// Conditional transition
"next": [
  {"if": "inputs.choice == 'A'", "id": "PATH_A"},
  {"if": "inputs.choice == 'B'", "id": "PATH_B"},
  {"id": "DEFAULT_PATH"}  // Fallback (no condition)
]

// Terminal step (workflow ends)
"next": []
// Or simply omit the `next` field
```

### Evaluation Order

Transitions are evaluated **in order**. The first matching entry wins:

1. Iterate through `next` array from first to last.
2. For each entry: if it has an `if` condition, evaluate it.
3. If condition is true (or no condition), transition to that step.
4. If no entries match, the submission is treated as terminal and the workflow completes in place.

**Best practice:** always include a fallback entry without `if` as the last item:

```json theme={null}
"next": [
  {"if": "score >= `90`", "id": "EXCELLENT"},
  {"if": "score >= `70`", "id": "GOOD"},
  {"id": "NEEDS_IMPROVEMENT"}  // Catches everything else
]
```

### Terminal Steps

A step is **terminal** if:

* It has no `next` field, OR
* It has `"next": []` (empty array).

When a terminal step is submitted:

1. The workflow is marked as completed.
2. The submit tool is removed from the agent's available tools.
3. The workflow state is preserved for reference.

**Important:** a terminal step does not complete just because the workflow enters it. Completion happens when the submit tool is called on that step. For terminal steps with no required inputs, your instructions or `tools.call: true` still need to drive that final submit.

### Loop-Back Transitions

Two distinct cases look similar but behave differently at runtime:

| Pattern                                          | Example                                          | `on.enter` re-fires?                                    | Inputs preserved?                                                         |
| ------------------------------------------------ | ------------------------------------------------ | ------------------------------------------------------- | ------------------------------------------------------------------------- |
| **Same-step loop** (retry the current step)      | `next: [{id: "VERIFY_INFO"}]` from `VERIFY_INFO` | No                                                      | Yes — accumulated inputs are kept so the agent can fill in missing values |
| **Earlier-step jump** (restart an upstream step) | `next: [{id: "ASK_PHONE"}]` from a later step    | Yes — the engine treats it as entering a different step | No — `inputs.*` is cleared as for any other transition                    |

A typical retry loop uses the same-step form:

```json theme={null}
{
  "id": "VERIFY_INFO",
  "inputs": [{"name": "provided_dob", "type": "string", "required": true}],
  "on": {
    "submit": [
      {"action": "inc", "name": "local.attempts", "if": "inputs.provided_dob != patient_dob"}
    ]
  },
  "next": [
    {"if": "inputs.provided_dob == patient_dob", "id": "VERIFIED"},
    {"if": "local.attempts >= `3`", "id": "FAILED"},
    {"id": "VERIFY_INFO"}  // Same-step loop: keep inputs, no re-enter
  ]
}
```

***

## Tool Configuration

The `tools` field on a step controls which tools the agent can access and whether to force specific tool behavior.

### Configuration Options

| Field                 | Type          | Default    | Description                                                  |
| --------------------- | ------------- | ---------- | ------------------------------------------------------------ |
| `tools.allow`         | list\[string] | null (all) | Whitelist of allowed tool names                              |
| `tools.call`          | boolean       | false      | Force submit tool (auto-advance) when no pending call action |
| `tools.allowGoToStep` | boolean       | false      | Expose manual step navigation                                |

### tools.allow

Restricts which tools the agent can see during this step. The submit tool is always available regardless of this setting.

```json theme={null}
{
  "id": "COLLECT_NAME",
  "tools": {
    "allow": ["submit_contact_form"]  // Only the submit tool
  }
}

{
  "id": "VERIFY_EMAIL",
  "tools": {
    "allow": ["submit_contact_form", "validate_email_domain"]  // Submit + one external tool
  }
}
```

**Use cases:**

* Prevent agent from calling irrelevant tools during sensitive steps.
* Progressive disclosure: unlock tools as workflow progresses.
* Security: restrict access to sensitive operations.

**Special values:**

* **Omitted / `null`**: no restriction — all tools visible.
* **`[]` (empty list)**: no external tools — only the submit tool is available. This is the standard setting for bridge steps that should auto-advance without any LLM tool decisions.

**Interaction with `call` actions:**

* **Hint path** (LLM-guided): `tools.allow` acts as a guardrail. If the `call` action's target tool is not in the allow-list (and is not the submit tool), the pending call is discarded.
* **Inject path** (synthetic): `tools.allow` is **not** enforced — the step author explicitly named the tool in the `call` action, so intent is clear.

### tools.call

When `true`, forces the LLM to produce a tool call on turns where no pending `call` action exists. In practice this means it forces the **submit tool**, which is the primary use case: auto-advancing bridge steps and terminal steps that have no inputs to collect.

**How it interacts with `call` actions:**

`tools.call` and the `call` action operate on **different turns** and compose naturally:

1. **Turn with pending call:** the `call` action takes priority — it forces `tool_choice` to the target tool. `tools.call` has no additional effect.
2. **Turn without pending call:** `tools.call: true` kicks in — it forces the submit tool (or `tool_choice: required` if `tools.allow` is set).

This means you do **not** need `tools.call: true` for a `call` action to work. But combining them is the standard pattern for fully automatic steps.

**Bridge steps (non-terminal zero-input steps):** if a step has no `inputs` and a `next` transition, the LLM has nothing to collect and will not automatically submit. This causes the workflow to stall. Add `"tools": {"call": true}` to force submission.

A typical bridge step that fetches data and then auto-advances:

```json theme={null}
{
  "id": "ROUTE",
  "goal": "Determine next step based on caller data",
  "inputs": [],
  "tools": {"call": true, "allow": []},
  "on": {
    "enter": [
      {"action": "call", "name": "lookup_caller", "arguments": {"ani": "{{vars.session.source}}"}}
    ]
  },
  "next": [
    {"if": "matched_caller", "id": "MATCHED"},
    {"id": "UNMATCHED"}
  ]
}
```

Here the `call` action handles the external tool call (inject path, since `ani` is provided). Then on the next turn, `tools.call: true` with `allow: []` forces the submit tool, advancing the workflow without any LLM decision-making.

Similarly, terminal steps with no inputs still require the submit tool to be called to mark the workflow complete. Include a clear instruction or `tools.call: true` to ensure submission happens.

### tools.allowGoToStep

When `true`, adds a `go_to_step` parameter to the submit tool schema, allowing the agent to manually jump to a specific step.

```json theme={null}
{
  "id": "MENU",
  "goal": "Present options to the user",
  "tools": {
    "allowGoToStep": true
  },
  "instructions": [
    "Ask the user what they'd like to do:",
    "1. Check balance → go to CHECK_BALANCE",
    "2. Make payment → go to MAKE_PAYMENT",
    "3. Speak to agent → go to TRANSFER"
  ]
}
```

**Generated tool parameters schema includes:**

```json theme={null}
{
  "go_to_step": {
    "type": "string",
    "description": "Optional: jump to a specific step ID"
  }
}
```

**Notes:**

* The agent chooses the step by name.
* Invalid step IDs return a validation error.
* Bypasses normal `next` evaluation when used.
* The current implementation validates the target at submit time; it does not enumerate valid step IDs in the generated schema.
* Use with caution: letting the LLM choose the next step makes the workflow more flexible, but also less predictable.

***

## Lifecycle Hooks

Lifecycle hooks execute actions at specific points during step execution. Define them in the `on` property:

```json theme={null}
{
  "on": {
    "start": [...],
    "enter": [...],
    "presubmit": [...],
    "submit": [...]
  }
}
```

### Event Summary

| Event          | When Triggered                                   | Allowed Actions                     | Primary Use Cases                                |
| -------------- | ------------------------------------------------ | ----------------------------------- | ------------------------------------------------ |
| `on.start`     | First workflow turn, before the first `on.enter` | `set`, `inc`, `say`, `call`         | One-time initialization, first-turn side effects |
| `on.enter`     | Entering a step (before input collection)        | `get`, `set`, `inc`, `say`, `call`  | Step welcome messages, pre-populate inputs       |
| `on.presubmit` | After agent submits, before validation           | `get`, `set`, `inc`, `save`         | Default missing values, data transformation      |
| `on.submit`    | After validation passes                          | `set`, `inc`, `say`, `save`, `call` | Persist data, trigger side effects               |

### on.start

Executes once at workflow initialization, before `on.enter` on the first step.

**Common uses:**

* Initialize workflow-local state.
* Queue first-turn `say` or `call` actions.
* Seed values that the first step's instructions depend on.

```json theme={null}
{
  "on": {
    "start": [
      {"action": "set", "name": "local.user_language", "value": "English"}
    ]
  }
}
```

**Restriction:** only the first step may define `on.start`.

### on.enter

Executes when the workflow enters this step, before the agent begins input collection.

**Common uses:**

* Display step-specific greetings or progress indicators.
* Pre-populate inputs from existing data.
* Initialize step-local counters.

```json theme={null}
{
  "on": {
    "enter": [
      {"action": "say", "text": "Step 2 of 3: Email collection"},
      {"action": "get", "inputs": ["user_email"]}
    ]
  }
}
```

### on.presubmit

Executes after the agent calls the submit tool, but *before* input validation runs.

**Restriction:** no `say` or `call` actions (data mutation only). This prevents confusing UX where the agent says "Saved!" but validation then fails.

**Common uses:**

* Default missing optional fields.
* Transform input values before validation.

```json theme={null}
{
  "on": {
    "presubmit": [
      {
        "if": "is_false(inputs.middle_name)",
        "action": "set",
        "name": "inputs.middle_name",
        "value": ""
      }
    ]
  }
}
```

### on.submit

Executes after validation passes, before evaluating transitions.

**Common uses:**

* Persist inputs to global variables.
* Update counters.
* Confirm submission to user.
* Trigger external tool calls.

```json theme={null}
{
  "on": {
    "submit": [
      {"action": "save"},
      {"action": "inc", "name": "local.steps_completed"},
      {"action": "say", "text": "Information saved!"}
    ]
  }
}
```

***

## Runtime Lifecycle

The hook table above says *what* each event means. This section explains *when* they fire at runtime, how many agent turns a single step submission takes, and a handful of behaviours that commonly surprise workflow authors (latency, auto-submitting "bridge" steps, and what happens when `call` actions stack up across a transition).

### Timescales

A workflow runs at three nested timescales. Keeping them separate in your head makes the firing rules below much easier to follow:

| Timescale        | Lives for                                                            | Events that fire at this scale                                |
| ---------------- | -------------------------------------------------------------------- | ------------------------------------------------------------- |
| **Conversation** | One call or chat session with the user                               | `on.start` — once, on the first step                          |
| **Step visit**   | From the moment the workflow enters a step until it transitions away | `on.enter` — once per entry                                   |
| **Submission**   | One call to the submit tool by the agent                             | `on.presubmit`, validation, `on.submit` — each submit attempt |

A single conversation typically contains several step visits, and a single step visit can contain several submissions (e.g., the agent submits with a missing field, the engine returns a validation error, the agent submits again with the missing field filled in).

### Hook Firing Order

Use this as the definitive ordering when you are deciding which hook should contain a given action:

| Trigger                              | Hook                          | How often                   |
| ------------------------------------ | ----------------------------- | --------------------------- |
| Workflow begins                      | `on.start` (first step only)  | Once per conversation       |
| Workflow or transition enters a step | `on.enter` (target step)      | Once per *distinct* entry   |
| Agent calls the submit tool          | `on.presubmit` (current step) | Every submission            |
| Inputs validated                     | `on.submit` (current step)    | Every successful submission |

**Same-step loops do not re-fire `on.enter`.** If `next` routes the workflow back to the *current* step (a retry loop), inputs are preserved and `on.enter` is **not** run again. A transition to any *different* step — including jumping back to an earlier step — re-fires `on.enter` on the target step and clears `inputs.*`.

**`on.presubmit` always runs before validation, even on attempts that will fail.** Use it to default or normalize inputs so validation can succeed. It is deliberately restricted from `say` and `call` actions to avoid user-visible side effects that happen before the engine has confirmed the inputs are good.

### What Happens in One Submission

Every time the agent calls the submit tool, the engine walks through this sequence before it returns anything to the agent:

```mermaid theme={null}
sequenceDiagram
    autonumber
    participant A as Agent
    participant E as Workflow engine

    A->>E: submit(inputs) for Step A1
    activate E
    Note over E: on.presubmit (Step A1)
    Note over E: validate inputs
    alt Validation fails
        E-->>A: Step A1 instructions<br/>+ missing_required hint
    else Validation passes
        Note over E: on.submit (Step A1)
        Note over E: evaluate `next` transitions
        alt Loop back to Step A1
            E-->>A: Step A1 instructions<br/>(inputs preserved)
        else Transition to Step A2
            Note over E: on.enter (Step A2)
            E-->>A: Step A2 instructions<br/>+ at most one pending tool call
        else No branch matches
            E-->>A: Step A1 response<br/>status: completed
        end
    end
    deactivate E
```

Things worth noting from this diagram:

* **`on.submit` and the new step's `on.enter` both run inside the same submission.** That's a single engine round — the agent sees only the *target* step's instructions in the response.
* **The engine never "waits" between `on.submit` and `on.enter`.** If either hook queues a `say` or `call` action, those actions accumulate in a single pending queue and are surfaced together in the response.
* **A terminal outcome (no branch matches) still returns a response** — the last step's `completed` status. The agent still needs to generate one final user-facing reply.

### Why Step Submissions Take Two Agent Turns

A common question is *"why does the step tool feel slow?"*. The answer is baked into the design: every step submission needs **at least two language-model turns** before the user hears a reply.

```mermaid theme={null}
sequenceDiagram
    autonumber
    actor U as User
    participant A as Agent
    participant E as Workflow engine

    U->>A: says something
    A->>E: submit(inputs)
    Note right of A: Turn 1 — tool call
    E-->>A: next step's instructions
    A->>U: speaks to the user
    Note right of A: Turn 2 — text reply
```

* **Turn 1** is the tool call itself. The agent decides to submit, the engine processes it, and the engine returns the next step's instructions. Nothing reaches the user yet.
* **Turn 2** is when the agent actually generates the user-facing reply, now informed by the new step's instructions.

**Practical implications:**

* Two turns is the *floor*, not the average. Every additional `call` action or bridge step adds more turns (see the next two subsections).
* The latency floor is roughly two model calls back-to-back. On voice channels, this is audible; on chat it usually isn't.
* Prefer small, fast models for latency-sensitive workflows. The absolute number of turns doesn't change with model choice, but each turn is quicker.
* `say` actions bypass the model-generated reply for that turn — the engine emits their text directly. Use `say` for short, compliance-critical phrases on voice when you want predictability and lower latency. *(note: coming soon)*

### Bridge Steps and the Internal Loop

A **bridge step** has no inputs — it exists purely to fetch data, run side effects, or pick the next branch. Bridge steps are almost always configured with `tools.call: true` so the engine forces the agent to submit immediately instead of speaking. That comes at a cost: each bridge step adds extra turns before the user hears anything.

```mermaid theme={null}
sequenceDiagram
    autonumber
    actor U as User
    participant A as Agent
    participant E as Workflow engine
    participant T as External tool

    U->>A: says something
    A->>E: submit(inputs) — Step 1 done
    E-->>A: Step 2 instructions<br/>+ pending call for Tool X
    A->>T: calls Tool X
    T-->>A: tool result
    A->>E: submit() — Step 2 done
    Note right of A: forced by tools.call:true
    E-->>A: Step 3 instructions
    A->>U: speaks to the user
```

Each bridge step contributes one tool-call turn plus one forced-submit turn — on top of the baseline two-turn submission. A chain of four bridge steps can easily use 8–10 model calls before the caller hears anything.

**Authoring guidance:**

* Keep bridge-step chains short. If you are stringing together five bridge steps to fetch and massage data, consider consolidating the work into a single custom endpoint.
* A bridge step that's waiting on a slow external tool is the most common cause of awkward silences on voice calls.
* Prefer one bridge step with one `call` action over several smaller bridges — fewer turns, less compounding latency.

### Multiple `call` Actions Across a Transition

The engine returns **at most one** pending tool call per response, even if your configuration produces several during the same submission. This is the scenario that surprises authors most often:

```json theme={null}
// Step A1 submits, then A2 enters — both have a call action
{
  "id": "A1",
  "on": {
    "submit": [{"action": "call", "name": "Tool_B", "arguments": {...}}]
  },
  "next": [{"id": "A2"}]
}

{
  "id": "A2",
  "on": {
    "enter": [{"action": "call", "name": "Tool_C", "arguments": {...}}]
  }
}
```

What actually happens when the agent submits A1:

1. A1's `on.submit` runs and queues a pending call for **Tool B**.
2. The workflow transitions to A2.
3. A2's `on.enter` runs and queues a pending call for **Tool C**.
4. The engine drains the queue and surfaces **only Tool B** in the response — it is the first call queued.
5. Tool C stays in the queue. It will only surface after the *next* submission.

That means Tool C fires much later than an author typically expects — it runs after Tool B *and* after the agent submits A2 again. It can also be silently discarded in edge cases: if Tool B is not in A2's `tools.allow` list, the engine drops Tool B to enforce the allow-list, and the transition effectively leaks a queued call that the author can no longer reason about.

**Authoring guidance:**

* Don't stack `call` actions across a transition boundary. Pick one: either A1's `on.submit` or A2's `on.enter`, not both.
* If you really need two external calls as part of one step change, route them through a single endpoint that fans out server-side, or add a dedicated bridge step between A1 and A2 with just the second call.
* If a step's `tools.allow` list is non-empty, make sure every `call` action that could arrive at this step names one of the allowed tools — otherwise the engine will discard it.

### Real-Time Voice Considerations

Workflows behave identically on chat and on real-time voice, but the latency cost of each extra turn is much more visible on voice. A few rules of thumb for voice-heavy workflows:

* **Every extra turn is audible.** On chat, the two-turn minimum feels instant. On voice, it's a noticeable pause. Bridge-step chains are the most common source of awkward silences.
* **Prefer `say` over asking the agent to repeat fixed phrases.** `say` text is emitted without an extra model turn, so it is both faster and more deterministic than "please say X".
* **Avoid `on.enter.call` for slow tools** on steps the workflow enters mid-conversation. Every enter-side call adds at least one turn before the agent can speak.
* **Keep `on.submit.call` arguments complete.** When all required parameters are supplied, the engine can inject the call without a second model turn. Leaving required parameters missing forces a hint-based path that costs an additional turn.

***

## Actions Reference

Actions are the operations executed during lifecycle events. All actions support an optional `if` condition.

### Action Summary

| Action | Description                                          | Key Parameters                             | Available In                        |
| ------ | ---------------------------------------------------- | ------------------------------------------ | ----------------------------------- |
| `say`  | Queue a verbatim message                             | `text`, `role`                             | `on.start`, `on.enter`, `on.submit` |
| `set`  | Set a variable value                                 | `name`, `value` or `valueFrom`             | all events                          |
| `inc`  | Increment a counter                                  | `name`, `by`                               | all events                          |
| `get`  | Pre-populate inputs from variables or a static value | `inputs`, `value`/`valueFrom`, `overwrite` | `on.enter`, `on.presubmit`          |
| `save` | Persist inputs to global variables                   | `name`, `inputs`                           | `on.presubmit`, `on.submit`         |
| `call` | Queue a tool call                                    | `name`, `arguments`                        | `on.start`, `on.enter`, `on.submit` |

### say

Queues text that the agent must include verbatim in its response.

```json theme={null}
{"action": "say", "text": "Welcome! Step 1 of 3."}
{"action": "say", "text": "Please hold while I check...", "role": "assistant"}
```

| Parameter | Type   | Default       | Description              |
| --------- | ------ | ------------- | ------------------------ |
| `text`    | string | required      | Verbatim text to include |
| `role`    | string | `"assistant"` | Message role             |
| `if`      | string | -             | Optional condition       |

**Note:** Agent compliance with `say` text is approximately 95%. The agent may occasionally add minor variations.

### set

Sets a variable to a static value or computed expression.

```json theme={null}
{"action": "set", "name": "local.counter", "value": 0}
{"action": "set", "name": "user_status", "valueFrom": "inputs.status"}
{"action": "set", "name": "full_name", "valueFrom": {"type": "cel", "expression": "first + ' ' + last"}}
{"action": "set", "name": "combined", "valueFrom": {"type": "jmespath", "expression": "{name: user_name, email: user_email}"}}
```

| Parameter   | Type       | Description                                                                                               |
| ----------- | ---------- | --------------------------------------------------------------------------------------------------------- |
| `name`      | string     | Target variable path (e.g., `local.x`, `user_name`)                                                       |
| `value`     | any        | Static value to set. String values are template-expanded (supports `{{var}}`, `${var}`, `${var=default}`) |
| `valueFrom` | Expression | Dynamic value from expression (mutually exclusive with `value`). May return any type including objects    |
| `if`        | string     | Optional condition                                                                                        |

**String `value` template expansion:** when `value` is a string, it is template-expanded at write time. All three template syntaxes are supported — for example, `"${missing_var=FALLBACK}"` resolves to `"FALLBACK"` if the variable doesn't exist.

**Object results from `valueFrom`:** when a JMESPath expression returns an object (e.g., `{name: user_name, email: user_email}`), the entire object is stored as a single variable. You can reference the whole object in templates (`{{combined}}` renders the object representation) or access nested fields (`{{combined.name}}`). Object values are also navigable in JMESPath conditions.

### inc

Increments a numeric variable. If the variable does not exist, it is initialized to `by` (so `inc` of a missing counter with the default `by: 1` ends up at `1`).

```json theme={null}
{"action": "inc", "name": "local.attempt_count"}
{"action": "inc", "name": "local.score", "by": 10}
{"action": "inc", "name": "local.retries", "if": "!inputs.is_valid"}
```

| Parameter | Type   | Default  | Description            |
| --------- | ------ | -------- | ---------------------- |
| `name`    | string | required | Variable to increment  |
| `by`      | number | 1        | Amount to increment by |
| `if`      | string | -        | Optional condition     |

**Non-numeric values are skipped.** If `name` already holds a non-numeric value, the action logs an error and does nothing rather than coercing or overwriting.

### get

Populates step inputs from existing variables or from a static/computed value. Useful for pre-filling forms with known data, or seeding defaults that the agent can override.

```json theme={null}
// Copy from variables of the same name (default mode)
{"action": "get", "inputs": ["user_email", "user_phone"]}

// Always overwrite, even if the agent already supplied a value
{"action": "get", "inputs": ["user_name"], "overwrite": true}

// Set a static value for one or more inputs
{"action": "get", "inputs": ["status"], "value": "pending"}

// Set inputs from a computed expression
{"action": "get", "inputs": ["full_name"], "valueFrom": "first_name"}
```

| Parameter   | Type          | Default    | Description                                                                     |
| ----------- | ------------- | ---------- | ------------------------------------------------------------------------------- |
| `inputs`    | list\[string] | all inputs | Which inputs to populate                                                        |
| `value`     | any           | -          | Static value (template-expanded if string). Mutually exclusive with `valueFrom` |
| `valueFrom` | Expression    | -          | Computed value (JMESPath/CEL). Mutually exclusive with `value`                  |
| `overwrite` | boolean       | false      | If true, overwrite existing input values. Otherwise, only fill empty inputs     |
| `if`        | string        | -          | Optional condition                                                              |

**Mode selection:**

* If neither `value` nor `valueFrom` is set, the action copies each input from a global variable of the same name (e.g. `inputs.user_email ← user_email`).
* If `value` or `valueFrom` is set, every named input receives that same resolved value.

**Enum coercion.** When the target input declares an `enum`, `get` matches the resolved value case-insensitively against the enum and writes the canonical form. A non-matching value is skipped rather than written.

**Alias.** The action name `"load"` is accepted as a synonym for `"get"` for compatibility; new tool definitions should use `"get"`.

### save

Saves step inputs to global variables for use in later steps or after workflow completion. `save` is a shortcut for one or more equivalent `set` actions that copy from `inputs.*`.

```json theme={null}
{"action": "save"}
{"action": "save", "inputs": ["user_name", "user_email"]}
{"action": "save", "name": "contact", "inputs": ["user_name", "user_email"]}
```

| Parameter | Type          | Default    | Description                                         |
| --------- | ------------- | ---------- | --------------------------------------------------- |
| `inputs`  | list\[string] | all inputs | Which inputs to save                                |
| `name`    | string        | (none)     | **Parent prefix** prepended to each saved input key |
| `if`      | string        | -          | Optional condition                                  |

**How `name` is applied:** `name` is a parent prefix, not a target variable name. For each saved input `X`:

* No `name` → writes key `X` (for example, `user_email`).
* With `name: "contact"` → writes key `contact.X` (for example, `contact.user_email`).

So `{"action": "save", "name": "contact", "inputs": ["user_email"]}` writes `contact.user_email`. It does **not** assign the input value to a variable called `contact`.

**Collision cleanup when a scalar already exists at the prefix.** If a scalar variable already exists at the `name` you pass in, writing a nested key under that same root removes the existing scalar — the workflow engine does not allow a scalar and a nested shape to coexist at the same root. So `{"action": "save", "name": "contact", "inputs": ["user_email"]}` while `contact = "Alice"` already exists will:

1. Delete the existing `contact = "Alice"`.
2. Write `contact.user_email = "<the input value>"`.

The result is usually **not** what the author intended: the original `contact` value is gone, and the new value is stored under a different key than expected.

**Common intent: overwriting an existing variable.** If you want to replace the value of an existing variable (for example, a campaign variable under `vars.*`) with an input value, use `set`, not `save`:

```json theme={null}
// Overwrites vars.facility_email with the collected input value
{"action": "set", "name": "vars.facility_email", "valueFrom": "inputs.obtained_email"}
```

Using `{"action": "save", "name": "vars.facility_email", "inputs": ["obtained_email"]}` would instead delete the original `vars.facility_email` scalar and create a new key `vars.facility_email.obtained_email` — downstream steps reading `{{vars.facility_email}}` would then see an empty value.

**Without parameters:** `{"action": "save"}` saves every step input to a global variable with the same name (equivalent to calling `set` for each input).

### call

Invokes an external tool call from a lifecycle event. The workflow engine automatically decides the routing path based on whether the `arguments` supply all of the target tool's required parameter keys.

```json theme={null}
{"action": "call", "name": "get_current_datetime"}
{"action": "call", "name": "lookup_patient", "arguments": {"patient_id": "{{inputs.patient_id}}"}}
```

| Parameter   | Type   | Description                                                                   |
| ----------- | ------ | ----------------------------------------------------------------------------- |
| `name`      | string | Tool name to call                                                             |
| `arguments` | object | Template-rendered arguments (keys matched against the tool's required params) |
| `if`        | string | Optional condition                                                            |

#### Auto-decide routing

After template rendering, the engine looks up the target tool's required parameters and checks whether all required parameter **names** are present as **keys** in the rendered `arguments`:

| Required params supplied?      | Route                  | Behavior                                                                                                       |
| ------------------------------ | ---------------------- | -------------------------------------------------------------------------------------------------------------- |
| All present (or tool has none) | **Inject** (synthetic) | Bypass LLM — inject the tool call and response directly into conversation history. Single turn.                |
| Some missing                   | **Hint** (LLM-guided)  | Queue a `pending_tool_call` and force `tool_choice` so the LLM generates the call on the next turn. Two turns. |
| Tool not in registry           | **Hint** (fallback)    | Can't determine completeness — safe default to LLM-guided.                                                     |

Only key **presence** matters — values like empty string, `null`, `0`, or `false` are all accepted. If you want the LLM to fill in a parameter value, omit that key from `arguments`.

**Self-sufficiency:** a `call` action forces `tool_choice` independently of `tools.call`. You do **not** need `tools.call: true` for a `call` action to work. However, most bridge-style steps combine both: the `call` action handles the external tool, and `tools.call: true` forces the submit tool on the subsequent turn to auto-advance.

#### Common patterns

**Inject — all required params provided (single turn):**

```json theme={null}
{
  "on": {
    "enter": [
      {"action": "call", "name": "lookup_patient", "arguments": {"patient_id": "{{patient_id}}"}}
    ]
  }
}
```

The required key `patient_id` is present, so the engine injects the tool call synthetically — the LLM is never involved.

**Hint — required params omitted, LLM fills them (two turns):**

```json theme={null}
{
  "instructions": ["Call mock_patient_lookup with patient_id 'patient-456'."],
  "on": {
    "enter": [
      {"action": "call", "name": "mock_patient_lookup", "arguments": {}}
    ]
  }
}
```

The required key `patient_id` is missing from `arguments`, so the engine queues a `pending_tool_call` hint. The LLM sees the hint and generates the tool call with the value from its instructions.

**Inject + auto-advance — zero-input bridge step:**

```json theme={null}
{
  "id": "CHECK_TIME",
  "goal": "Record the current date and time, then auto-advance",
  "tools": {"call": true, "allow": []},
  "on": {
    "enter": [
      {"action": "call", "name": "get_current_datetime", "arguments": {}}
    ]
  },
  "next": [{"id": "NEXT_STEP"}]
}
```

`get_current_datetime` has no required params → inject. Then `tools.call: true` with `allow: []` forces the submit tool on the next turn, making the step fully automatic.

#### Tool results

**Important:** the workflow engine does not automatically copy the tool result into `inputs.*` or `local.*`. If later branching depends on the tool result, either:

* have the endpoint persist known values into `vars.*`, or
* have the agent resubmit normalized result fields through the submit tool.

### Conditional Actions

All actions support an optional `if` field for conditional execution:

```json theme={null}
{
  "action": "say",
  "text": "That doesn't match our records. Please try again.",
  "if": "inputs.provided_dob != patient_dob"
}
```

```json theme={null}
{
  "action": "inc",
  "name": "local.retry_count",
  "if": "!inputs.is_valid && local.retry_count < 3"
}
```

The `if` condition is evaluated as a JMESPath expression by default. See [Expressions](#expressions) for syntax details.

***

## Expressions

Expressions are used in conditions (`if` fields), computed values (`valueFrom`), and step transitions (`next[].if`).

### JMESPath (Default)

[JMESPath](https://jmespath.org/) is the default expression language. When you write a string condition, it's evaluated as JMESPath.

**Common Patterns:**

```json theme={null}
// Boolean check (truthy/falsy)
"if": "is_valid"
"if": "inputs.confirmed"

// String comparison
"if": "status == 'active'"
"if": "inputs.contact_time == 'morning'"

// Boolean literal comparison (note backticks!)
"if": "inputs.can_sign == `true`"
"if": "inputs.opted_out == `false`"

// Numeric comparison
"if": "local.attempt_count >= `3`"
"if": "inputs.age < `18`"

// Logical operators
"if": "is_valid && has_consent"
"if": "status == 'morning' || status == 'afternoon'"
"if": "!inputs.opted_out"

// Nested property access
"if": "inputs.address.city == 'Boston'"

// Null/missing check
"if": "inputs.middle_name"       // truthy check
"if": "!inputs.optional_field"   // missing or empty
```

**Gotchas:**

| Issue                                 | Wrong              | Correct                   |
| ------------------------------------- | ------------------ | ------------------------- |
| Step inputs need the `inputs.` prefix | `foo == \`true\`\` | `inputs.foo == \`true\`\` |
| Boolean literals need backticks       | `flag == true`     | `flag == \`true\`\`       |
| Number literals need backticks        | `count > 3`        | `count >= \`3\`\`         |
| String quotes                         | `name == morning`  | `name == 'morning'`       |

**Silent-failure mode worth calling out:** forgetting the `inputs.` prefix (e.g. writing `foo == \`true\``instead of`inputs.foo == \`true\``) does *not* raise an error. The bare name resolves against global scope, finds nothing, and the condition evaluates to false — so the branch never fires and the transition appears to "not work." Always prefix step-input references with `inputs.\`.

### CEL (Common Expression Language)

[CEL](https://github.com/google/cel-spec) is useful when you need features JMESPath doesn't support: arithmetic, string concatenation, or ternary operators.

**Syntax:**

```json theme={null}
{
  "valueFrom": {
    "type": "cel",
    "expression": "counter + 1"
  }
}

{
  "if": {
    "type": "cel",
    "expression": "age >= 18 ? 'adult' : 'minor'"
  }
}
```

**CEL Capabilities:**

```json theme={null}
// Arithmetic
"expression": "price * 0.9"
"expression": "local.attempts + 1"

// String concatenation
"expression": "first_name + ' ' + last_name"

// Ternary operator
"expression": "is_vip ? 'priority' : 'standard'"

// Boolean logic
"expression": "is_valid && has_consent"
```

**CEL Limitation:** CEL doesn't support nested dict access (`inputs.address.city`). Use JMESPath for nested structures.

### When to Use Which

| Use Case                | Recommended | Why                         |
| ----------------------- | ----------- | --------------------------- |
| Simple condition checks | JMESPath    | Default, no syntax overhead |
| String comparisons      | JMESPath    | Cleaner syntax              |
| Nested property access  | JMESPath    | CEL doesn't support it      |
| Arithmetic              | CEL         | JMESPath can't compute      |
| String building         | CEL         | JMESPath can't concatenate  |
| Ternary logic           | CEL         | Clean conditional values    |

***

## Templates

Template substitution lets you inject dynamic values into text fields using variable placeholders.

### Supported Syntax

| Syntax           | Behavior                                       | Example                                  |
| ---------------- | ---------------------------------------------- | ---------------------------------------- |
| `{{var}}`        | Replace with value, or empty string if missing | `{{user_name}}` → `"Alice"` or `""`      |
| `${var}`         | Replace with value, or empty string if missing | `${user_name}` → `"Alice"` or `""`       |
| `${var=default}` | Replace with value, or default if missing      | `${name=Guest}` → `"Alice"` or `"Guest"` |

**Recommendation:** Use `{{var}}` (handlebars style) for most cases.

**Object values in templates:** if a variable holds an object (from a JMESPath `valueFrom` expression), referencing it in a template renders its string representation. Access nested fields with dot notation: `{{combined.name}}` extracts the `name` field from the `combined` object.

### Where Templates Are Expanded

| Field                     | Template Expansion | Notes                                               |
| ------------------------- | ------------------ | --------------------------------------------------- |
| `instructions[]`          | Yes                | Step instructions are rendered with current context |
| `say` action `text`       | Yes                | Message text is rendered before queuing             |
| `set` action `value`      | Yes (if string)    | Static string values are rendered                   |
| `call` action `arguments` | Yes (recursive)    | All string values in arguments are rendered         |
| `goal`                    | No                 | Used as-is                                          |
| `if` conditions           | No                 | Use expression evaluation instead                   |
| `valueFrom`               | No                 | Use expression evaluation (JMESPath/CEL)            |

### Available Variables

| Scope            | Access Pattern            | Example                   |
| ---------------- | ------------------------- | ------------------------- |
| Global variables | `{{variable_name}}`       | `{{user_name}}`           |
| Task-local state | `{{local.key}}`           | `{{local.retry_count}}`   |
| Step inputs      | `{{inputs.field}}`        | `{{inputs.provided_dob}}` |
| Nested values    | `{{scope.path.to.value}}` | `{{inputs.address.city}}` |

### Examples

**In instructions:**

```json theme={null}
{
  "instructions": [
    "Confirm with {{inputs.user_name}} that their email is {{user_email}}."
  ]
}
```

**In say action:**

```json theme={null}
{
  "action": "say",
  "text": "Hello {{inputs.user_name}}! You have {{local.remaining_attempts}} attempts remaining."
}
```

**In call action:**

```json theme={null}
{
  "action": "call",
  "name": "lookup_patient",
  "arguments": {
    "patient_id": "{{patient_id}}",
    "dob": "{{inputs.provided_dob}}"
  }
}
```

***

## Variables

Step Workflows use multiple variable scopes for different purposes.

Under the hood, variable keys are stored as **flat strings** in conversation state (for example, `customer_id` or `customer.id`).
Values are typically scalars (strings, numbers, booleans), but can also be objects when a `set` action with `valueFrom` produces a dict result.
When templates and expressions run, flat keys are expanded into nested objects so that dotted paths like `{{vars.session.language}}` resolve correctly. This is where hierarchy conflicts can appear if you mix scalar and nested names for the same root key.

### Variable Scopes

| Scope           | Prefix     | Lifetime     | Use Case                            |
| --------------- | ---------- | ------------ | ----------------------------------- |
| **Global**      | (none)     | Conversation | Shared data, final outputs          |
| **Task-local**  | `local.*`  | Workflow     | Counters, flags, intermediate state |
| **Step inputs** | `inputs.*` | Current step | Collected input parameters          |

### Global Variables

Global variables persist for the entire conversation and are accessible to all tools and workflows.

```json theme={null}
{"action": "save"}  // Saves inputs to global scope
{"action": "set", "name": "user_name", "valueFrom": "inputs.name"}
```

Use global variables for:

* Data needed after the workflow completes.
* Sharing data between concurrent workflows.
* Final outputs that other systems will use.

**Recommended naming style:** flat `snake_case` identifiers (for example, `customer_id`, `customer_email`, `dob_verified`).

### Task-Local Variables

Task-local variables (`local.*`) are scoped to a single workflow instance.

```json theme={null}
{"action": "set", "name": "local.counter", "value": 0}
{"action": "inc", "name": "local.retry_attempts"}
```

Use task-local variables for:

* Retry counters within the workflow.
* Flags and intermediate state.
* Data that shouldn't persist after workflow completion.

### Step Inputs

Step inputs (`inputs.*`) contain the current step's collected parameters. See [Inputs Schema](#inputs-schema) for declaration and accumulation behavior.

**Key behaviors:**

* Cleared when transitioning to a different step.
* Accumulated across multiple submissions of the current step.
* Read-only in expressions; use `set` to modify.

| Context         | Available Inputs                        |
| --------------- | --------------------------------------- |
| `on.enter`      | Empty (step just started)               |
| `on.presubmit`  | Values from current submit call         |
| `on.submit`     | Validated values (all required present) |
| `if` conditions | Current accumulated values              |
| Templates       | Current accumulated values              |

### Hierarchy Conflict Handling

Conflicts happen when the same root key is used both as a scalar and as a parent object path.

Examples of conflicting names:

* `customer` (scalar) and `customer.id` (nested)
* `contact.email` and later `contact` (scalar)
* `foo`, `foo.bar`, and `foo.bar.baz` mixed in one workflow

The system handles these conflicts deterministically in two stages:

#### 1) Write-time conflict cleanup (when `set`/`save` writes)

When writing variables:

* Writing a nested key (e.g., `customer.id`) removes conflicting scalar parents (`customer`).
* Writing a parent key (e.g., `customer`) removes conflicting children (`customer.*`).
* Siblings (e.g., `customer.id`, `customer.email`) can coexist.

| Pattern                  | Write Sequence                                           | Stored Result                          |
| ------------------------ | -------------------------------------------------------- | -------------------------------------- |
| Parent scalar then child | `set customer="alice"` → `set customer.id="123"`         | `customer` removed, `customer.id` kept |
| Child then parent scalar | `set customer.id="123"` → `set customer="alice"`         | `customer.*` removed, `customer` kept  |
| Sibling nested keys      | `set customer.id="123"` + `set customer.email="a@b.com"` | both keys kept                         |

#### 2) Read-time context expansion (templates/expressions)

When flat keys are expanded to nested context:

* If a scalar leaf already exists at a parent path, deeper keys under that path are skipped.
* If a plain key appears where a nested object already exists, the plain key overwrites that nested object.

This is "leaf wins" behavior during context building.

### `vars.*` Prefix and Platform Variables

The `vars.` prefix is a naming convention used by platform-provided variables such as:

* **Session context:** `vars.session.id`, `vars.session.language`, `vars.session.source`
* **Campaign variables:** per-call values provided to outbound campaigns (for example `vars.customer_name`, `vars.appointment_time`)
* **Agent configuration:** values defined once on the agent and exposed to every session

Because the platform owns that namespace, avoid writing your own custom workflow data under `vars.*`. If you do need to overwrite a specific platform-provided value (for example, replacing a campaign variable with a corrected value collected from the caller), use `set` with `valueFrom` — not `save` — so the target key is overwritten exactly:

```json theme={null}
{"action": "set", "name": "vars.facility_email", "valueFrom": "inputs.obtained_email"}
```

### Pitfall: Mixing Scalars and Nested Keys for the Same Root

A common mistake is unintentionally creating both a scalar and a nested shape for the same root name. For example, if a campaign provides `vars.facility_email` as a string, and a step later runs `save` with `name: "facility_email"` and input `obtained_email`, the two keys are:

* `vars.facility_email = "alice@example.com"` (campaign value, scalar)
* `facility_email.obtained_email = "alice@example.com"` (workflow-written, nested under a different root)

These are two separate variables in different places. Worse, if a step writes the nested form directly under `vars` (e.g., `vars.facility_email.obtained_email`), the engine will clean up the collision by dropping the original scalar. Neither outcome is usually what the author intended.

**Rule of thumb:** decide up front whether a variable is a scalar or an object, and don't mix. If you need to update a scalar, overwrite it with `set`. If you need a grouped object, use a distinct root name (`facility_contact.email`, not `facility_email.anything`).

### When to Use Which Scope

| Scenario                             | Scope     | Why                      |
| ------------------------------------ | --------- | ------------------------ |
| Data needed after workflow completes | Global    | Persists beyond workflow |
| Retry counter within workflow        | `local.*` | Workflow-specific state  |
| Sharing between concurrent workflows | Global    | Task-local is isolated   |
| Sensitive intermediate data          | `local.*` | More contained scope     |

### Practical Naming Guidance

1. Prefer flat `snake_case` names for global outputs (`customer_id`, `customer_email`, `preferred_contact_time`).
2. Use `local.*` for per-workflow state (`local.retry_count`, `local.current_phase`).
3. If you intentionally use nested global keys (for readability), use a consistent object hierarchy such as `customer.id`, `customer.email`.
4. Do not mix scalar and nested forms for the same root key (`customer` and `customer.id`) unless replacement is intentional.
5. Avoid custom keys under `vars.*`; reserve that namespace for platform-managed/session context.
