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:
- Workflow Configuration — the shape of a workflow, including activation mode and composing multiple workflows in one agent.
- Step Configuration — the shape of a single step.
- Inputs Schema — declaring and reading parameters.
- Step Transitions — routing between steps.
- Tool Configuration — controlling tool visibility.
- Lifecycle Hooks — what fires when.
- Runtime Lifecycle — how a submission unfolds at runtime, plus the latency and bridge-step rules.
- Actions Reference — every action and its parameters.
- Expressions — JMESPath and CEL.
- Templates — variable substitution in strings.
- 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 | "auto" (default) or "manual" |
steps | Ordered 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:
| 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.
{
"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:
| 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 | [{name: "user_name", type: "string"}] |
on | Lifecycle hooks for actions — see Lifecycle Hooks | {start: [...], enter: [...], submit: [...]} |
next | Transition routing — see Step Transitions | ["NEXT_STEP"] or [{if: "...", id: "..."}] |
tools | Tool visibility control — see Tool Configuration | {allow: [...], call: true} |
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."
]
}
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
| 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).
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:
- Iterate through
next array from first to last.
- For each entry: if it has an
if condition, evaluate it.
- If condition is true (or no condition), transition to that step.
- 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:
- The workflow is marked as completed.
- The submit tool is removed from the agent’s available tools.
- 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:
{
"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
]
}
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 |
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.
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:
- Turn with pending call: the
call action takes priority — it forces tool_choice to the target tool. tools.call has no additional effect.
- 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.
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
| 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.
{
"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:
| 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:
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:
- A1’s
on.submit runs and queues a pending call for Tool B.
- The workflow transitions to A2.
- A2’s
on.enter runs and queues a pending call for Tool C.
- The engine drains the queue and surfaces only Tool B in the response — it is the first call queued.
- 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.
{"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.
{"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).
{"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.
// 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.*.
{"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:
- Delete the existing
contact = "Alice".
- 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}}"}}
| 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):
{
"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.
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:
| 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 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 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:
{
"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
| 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.
{"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 (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.
| 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.
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
| 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
- Prefer flat
snake_case names for global outputs (customer_id, customer_email, preferred_contact_time).
- Use
local.* for per-workflow state (local.retry_count, local.current_phase).
- If you intentionally use nested global keys (for readability), use a consistent object hierarchy such as
customer.id, customer.email.
- Do not mix scalar and nested forms for the same root key (
customer and customer.id) unless replacement is intentional.
- Avoid custom keys under
vars.*; reserve that namespace for platform-managed/session context.