Skip to main content

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.

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. The sections below are ordered the way you usually need them when authoring a workflow:
  1. Workflow Configuration — the shape of a workflow, including activation mode and composing multiple workflows in one agent.
  2. Step Configuration — the shape of a single step.
  3. Inputs Schema — declaring and reading parameters.
  4. Step Transitions — routing between steps.
  5. Tool Configuration — controlling tool visibility.
  6. Lifecycle Hooks — what fires when.
  7. Runtime Lifecycle — how a submission unfolds at runtime, plus the latency and bridge-step rules.
  8. Actions Reference — every action and its parameters.
  9. Expressions — JMESPath and CEL.
  10. Templates — variable substitution in strings.
  11. 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:
PropertyPurposeExample
idUnique workflow identifier (used for state isolation and diagnostics)"patient_lookup"
tool.nameSubmit tool name exposed to the agent"submit_patient_lookup"
startWhen the workflow’s activation lifecycle runs — see Activation Mode"auto" (default) or "manual"
stepsOrdered list of steps — see 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:
ValueBehavior
"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.
{
  "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) 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:
PropertyPurposeExample
idUnique step identifier"COLLECT_NAME"
goalBrief description (shown to the agent)"Collect the user's name"
instructionsAgent guidance (array of strings)["Ask for the user's full name."]
inputsParameters to collect (JSON Schema) — see Inputs Schema[{name: "user_name", type: "string"}]
onLifecycle hooks for actions — see Lifecycle Hooks{start: [...], enter: [...], submit: [...]}
nextTransition routing — see Step Transitions["NEXT_STEP"] or [{if: "...", id: "..."}]
toolsTool visibility control — see 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.
{
  "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:
{
  "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):
{
  "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.
{
  "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

FieldTypeRequiredDescription
namestringYesParameter name
typestringNoJSON Schema type: string, number, integer, boolean, object, array. Defaults to string
descriptionstringNoHuman-readable description shown to agent
requiredbooleanNoWhether parameter must be collected. Defaults to true
enumlist[str]NoAllowed values (agent constrained to these options)
formatstringNoFormat hint: date, time, date-time, email, uri, etc.
patternstringNoRegex 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).

Referencing inputs in expressions and templates

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.
{"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.
{
  "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 meantinputs.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 for the full scope list.

Step Transitions

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

Transition Basics

// 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:
"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:
PatternExampleon.enter re-fires?Inputs preserved?
Same-step loop (retry the current step)next: [{id: "VERIFY_INFO"}] from VERIFY_INFONoYes — 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 stepYes — the engine treats it as entering a different stepNo — inputs.* is cleared as for any other transition
A typical retry loop uses the same-step form:
{
  "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

FieldTypeDefaultDescription
tools.allowlist[string]null (all)Whitelist of allowed tool names
tools.callbooleanfalseForce submit tool (auto-advance) when no pending call action
tools.allowGoToStepbooleanfalseExpose 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.
{
  "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:
{
  "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.
{
  "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:
{
  "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:
{
  "on": {
    "start": [...],
    "enter": [...],
    "presubmit": [...],
    "submit": [...]
  }
}

Event Summary

EventWhen TriggeredAllowed ActionsPrimary Use Cases
on.startFirst workflow turn, before the first on.enterset, inc, say, callOne-time initialization, first-turn side effects
on.enterEntering a step (before input collection)get, set, inc, say, callStep welcome messages, pre-populate inputs
on.presubmitAfter agent submits, before validationget, set, inc, saveDefault missing values, data transformation
on.submitAfter validation passesset, inc, say, save, callPersist 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.
{
  "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.
{
  "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.
{
  "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.
{
  "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:
TimescaleLives forEvents that fire at this scale
ConversationOne call or chat session with the useron.start — once, on the first step
Step visitFrom the moment the workflow enters a step until it transitions awayon.enter — once per entry
SubmissionOne call to the submit tool by the agenton.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:
TriggerHookHow often
Workflow beginson.start (first step only)Once per conversation
Workflow or transition enters a stepon.enter (target step)Once per distinct entry
Agent calls the submit toolon.presubmit (current step)Every submission
Inputs validatedon.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: 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.
  • 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. 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:
// 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

ActionDescriptionKey ParametersAvailable In
sayQueue a verbatim messagetext, roleon.start, on.enter, on.submit
setSet a variable valuename, value or valueFromall events
incIncrement a countername, byall events
getPre-populate inputs from variables or a static valueinputs, value/valueFrom, overwriteon.enter, on.presubmit
savePersist inputs to global variablesname, inputson.presubmit, on.submit
callQueue a tool callname, argumentson.start, on.enter, on.submit

say

Queues text that the agent must include verbatim in its response.
{"action": "say", "text": "Welcome! Step 1 of 3."}
{"action": "say", "text": "Please hold while I check...", "role": "assistant"}
ParameterTypeDefaultDescription
textstringrequiredVerbatim text to include
rolestring"assistant"Message role
ifstring-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.
{"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}"}}
ParameterTypeDescription
namestringTarget variable path (e.g., local.x, user_name)
valueanyStatic value to set. String values are template-expanded (supports {{var}}, ${var}, ${var=default})
valueFromExpressionDynamic value from expression (mutually exclusive with value). May return any type including objects
ifstringOptional 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).
{"action": "inc", "name": "local.attempt_count"}
{"action": "inc", "name": "local.score", "by": 10}
{"action": "inc", "name": "local.retries", "if": "!inputs.is_valid"}
ParameterTypeDefaultDescription
namestringrequiredVariable to increment
bynumber1Amount to increment by
ifstring-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.
// 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"}
ParameterTypeDefaultDescription
inputslist[string]all inputsWhich inputs to populate
valueany-Static value (template-expanded if string). Mutually exclusive with valueFrom
valueFromExpression-Computed value (JMESPath/CEL). Mutually exclusive with value
overwritebooleanfalseIf true, overwrite existing input values. Otherwise, only fill empty inputs
ifstring-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.*.
{"action": "save"}
{"action": "save", "inputs": ["user_name", "user_email"]}
{"action": "save", "name": "contact", "inputs": ["user_name", "user_email"]}
ParameterTypeDefaultDescription
inputslist[string]all inputsWhich inputs to save
namestring(none)Parent prefix prepended to each saved input key
ifstring-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:
// 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.
{"action": "call", "name": "get_current_datetime"}
{"action": "call", "name": "lookup_patient", "arguments": {"patient_id": "{{inputs.patient_id}}"}}
ParameterTypeDescription
namestringTool name to call
argumentsobjectTemplate-rendered arguments (keys matched against the tool’s required params)
ifstringOptional 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?RouteBehavior
All present (or tool has none)Inject (synthetic)Bypass LLM — inject the tool call and response directly into conversation history. Single turn.
Some missingHint (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 registryHint (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):
{
  "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):
{
  "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:
{
  "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:
{
  "action": "say",
  "text": "That doesn't match our records. Please try again.",
  "if": "inputs.provided_dob != patient_dob"
}
{
  "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 for syntax details.

Expressions

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

JMESPath (Default)

JMESPath is the default expression language. When you write a string condition, it’s evaluated as JMESPath. Common Patterns:
// 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:
IssueWrongCorrect
Step inputs need the inputs. prefixfoo == \true“inputs.foo == \true“
Boolean literals need backticksflag == trueflag == \true“
Number literals need backtickscount > 3count >= \3“
String quotesname == morningname == 'morning'
Silent-failure mode worth calling out: forgetting the inputs. prefix (e.g. writing foo == \true`instead ofinputs.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 is useful when you need features JMESPath doesn’t support: arithmetic, string concatenation, or ternary operators. Syntax:
{
  "valueFrom": {
    "type": "cel",
    "expression": "counter + 1"
  }
}

{
  "if": {
    "type": "cel",
    "expression": "age >= 18 ? 'adult' : 'minor'"
  }
}
CEL Capabilities:
// 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 CaseRecommendedWhy
Simple condition checksJMESPathDefault, no syntax overhead
String comparisonsJMESPathCleaner syntax
Nested property accessJMESPathCEL doesn’t support it
ArithmeticCELJMESPath can’t compute
String buildingCELJMESPath can’t concatenate
Ternary logicCELClean conditional values

Templates

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

Supported Syntax

SyntaxBehaviorExample
{{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

FieldTemplate ExpansionNotes
instructions[]YesStep instructions are rendered with current context
say action textYesMessage text is rendered before queuing
set action valueYes (if string)Static string values are rendered
call action argumentsYes (recursive)All string values in arguments are rendered
goalNoUsed as-is
if conditionsNoUse expression evaluation instead
valueFromNoUse expression evaluation (JMESPath/CEL)

Available Variables

ScopeAccess PatternExample
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:
{
  "instructions": [
    "Confirm with {{inputs.user_name}} that their email is {{user_email}}."
  ]
}
In say action:
{
  "action": "say",
  "text": "Hello {{inputs.user_name}}! You have {{local.remaining_attempts}} attempts remaining."
}
In call action:
{
  "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

ScopePrefixLifetimeUse Case
Global(none)ConversationShared data, final outputs
Task-locallocal.*WorkflowCounters, flags, intermediate state
Step inputsinputs.*Current stepCollected input parameters

Global Variables

Global variables persist for the entire conversation and are accessible to all tools and workflows.
{"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.
{"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 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.
ContextAvailable Inputs
on.enterEmpty (step just started)
on.presubmitValues from current submit call
on.submitValidated values (all required present)
if conditionsCurrent accumulated values
TemplatesCurrent 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.
PatternWrite SequenceStored Result
Parent scalar then childset customer="alice"set customer.id="123"customer removed, customer.id kept
Child then parent scalarset customer.id="123"set customer="alice"customer.* removed, customer kept
Sibling nested keysset 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:
{"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

ScenarioScopeWhy
Data needed after workflow completesGlobalPersists beyond workflow
Retry counter within workflowlocal.*Workflow-specific state
Sharing between concurrent workflowsGlobalTask-local is isolated
Sensitive intermediate datalocal.*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.