Workflows & DSL

Under the visual editor, every workflow is a JSON document — a recursive tree of typed nodes. You rarely need to write this JSON by hand, but understanding the structure helps you debug runs, review versions, and use the API confidently.

Best way to learn: The fastest path to understanding workflows is to explore a real one. After logging in, go to Workflows → New → Templates and open any example — you can inspect every node, edit it live, and run it immediately. Come back to this page as a reference while you explore.

Here's what a simple NPS feedback workflow looks like in the canvas editor — six nodes, one conditional branch, and an AI summarisation step at the end:

feedback-survey · canvas view
truefalse
MESSAGErespondent
welcome
Thanks for trying us! Two quick questions.
INPUTrespondent
score
How likely are you to recommend us? · Rating / nps
CONDITIONAL
branch
score ≤ 6
INPUTrespondent
why
Sorry to hear that — what went wrong? · TextArea
MESSAGErespondent
yay
Glad you like it!
AGENT
summarise
Summarise feedback: score {{score}}, comment {{why}}

The JSON definition for this exact workflow is in the Complete example section below.

Anatomy of a workflow

A workflow definition is a single root Workflow node whose children execute in order. Children can be nested groups, so the whole document forms a tree:

json
{
  "type": "Workflow",
  "id": "support-triage",
  "participants": [
    { "name": "customer", "bind": "run_initiator", "surface": "widget" }
  ],
  "children": [
    { "type": "Message", "id": "hello", "content": "Hi! Tell us what happened." },
    { "type": "Input", "id": "issue", "prompt": "Describe the issue",
      "input_type": "TextArea", "participant_role": "customer" },
    { "type": "Agent", "id": "triage",
      "prompt": "Classify this support issue: {{issue}}" }
  ]
}

Each node has a type discriminator, a unique id among its siblings, and writes its result into run state under that id — so later nodes can reference {{issue}} or {{triage.urgency}}.

The authoritative JSON Schema is public at GET /api/v1/schema/workflow — point your editor or CI at it for instant validation.

Versioning: The root node carries a dsl_versionstring that the platform sets automatically when you save or deploy. You don't author it by hand, and the editor preserves it on every edit. It lets the engine forward-migrate older definitions as the DSL evolves, so existing deployments keep running unchanged.

Node types

TypeWhat it does
WorkflowContainer / group. Executes children sequentially; with ui: "form" it renders child Inputs as one simultaneous form.
AgentLLM-backed step. Runs once as a standard agent call, or set mode: "converse" to drive a multi-turn adaptive collection — the LLM chooses what field type to show each turn and completes when its goal is met.
InputAsks a participant one question (Text, Number, Rating, FileUpload, Location…) and stores the validated answer.
MessageSends a one-way message (plain or markdown) to a participant. No response expected.
ToolCalls a built-in tool or a connection tool (slug.operation) with templated arguments.
CodeRuns a sandboxed JavaScript snippet with templated input — for transforms, scoring, glue logic.
ConditionalIf/else branching over state values using condition operators.
ParallelRuns independent durable branches concurrently and joins after a success quorum.
LoopRepeats its children while a condition holds.
RepeaterIterates children over a collection (e.g. one pass per item).
WorkflowCallInvokes another workflow as a sub-workflow, mapping inputs and outputs.
DelayPauses for a duration, or until an ISO 8601 timestamp / templated expression.
EditorCollaborative document or image editing session with a participant.
CreateDocumentBuilds a downloadable document (PDF, HTML, Word, Markdown…) from content for later download.
ShowDocumentShows a document to a participant with an inline preview and download buttons.
CustomEscape hatch for custom step implementations.
Agent node modes: An Agent node can run in two modes. In the default mode it executes once and returns a response (optionally structured via output_schema). With mode: "converse" it becomes a multi-turn collector: the LLM drives the conversation by asking one rich input field per turn (the same input_type vocabulary as Inputnodes), adapting to prior answers, and completing when its goal is met. The collected answers are stored under the node's id and are readable downstream as {{intake.field_name}}. Use voice_defaults to set STT capture settings for all turns on voice surfaces (incompatible with Gemini Live).

Bulk execution

Use Parallel for a small static set of durable branches. Use WorkflowCall.for_each when one child workflow should process an array. Durable mode creates recoverable child runs; ephemeral mode runs an eligible synchronous target inline.

Warning: Ephemeral execution is at least once. It creates no child runs or item recovery records, so a worker restart can repeat accepted provider calls. Validate the active target deployment, acknowledge repetition, and use provider idempotency. See the parallel execution guide.

Template variables

Any string field can reference run state with {{path.to.value}}. Paths resolve with scope walking — the engine looks in the current group scope first, then parents, then root state:

text
{{issue}}                 → value captured by the "issue" node
{{triage.urgency}}        → field extracted by the "triage" agent
{{personal.name}}         → value inside the "personal" group

When a string is only a template reference (e.g. "{{search.results}}"), the raw value is passed through with its native type — arrays stay arrays. Mixed strings like "Hello {{name}}" resolve to text.

Declared run inputs

The root node's optional inputs array declares the typed fields callers supply when starting a run via POST /api/v1/runs:

json
"inputs": [
  { "id": "name", "input_type": "Text", "required": true },
  { "id": "plan", "input_type": "SingleSelect",
    "options": ["free", "pro", "enterprise"], "default": "free" }
]

The API validates the input payload against these declarations and rejects bad requests with 422. Validated values are written into initial run state, reachable as {{name}}, {{plan}}, etc. Workflows without declared inputs accept arbitrary JSON.

Participants & surfaces

The root participants array names everyone involved in a run and defines two things per participant:

  • Bind strategy — how the name resolves to real user(s): run_initiator, deployer, static (fixed email / user id), tenant_member, team_member, or unbound.
  • Surface — the transport they communicate over: widget, widget/voice, inbox, form, phone, messaging/whatsapp, messaging/telegram, and more. Voice is a mode of a transport, selected via config.style (push_to_talk, continuous, live). See Surfaces & transports.
json
"participants": [
  { "name": "requester", "bind": "run_initiator", "surface": "widget" },
  { "name": "caller", "bind": "run_initiator", "surface": "widget/voice", "config": { "style": "live" } },
  { "name": "approver", "bind": "team_member", "team": "finance", "surface": "inbox" }
]

HITL nodes (Input, Agent, Message…) target a participant via participant_role. External participants join with participant tokens matching that role name.

Branching & conditions

Conditional and Loop nodes evaluate conditions over state. Operators include eq, neq, gt, gte, lt, lte, contains, starts_with, in, exists and their negations. Conditions compose with and / or / not:

json
{
  "type": "Conditional",
  "id": "route",
  "condition": {
    "type": "and",
    "conditions": [
      { "field": "triage.urgency", "op": "eq", "value": "high" },
      { "field": "plan", "op": "in", "value": ["pro", "enterprise"] }
    ]
  },
  "then": [ { "type": "Message", "id": "fast", "content": "Priority lane!" } ],
  "else": [ { "type": "Message", "id": "norm", "content": "We'll be in touch." } ]
}

Conversational agents

Set an Agent to mode: "converse" when it should gather structured information over several adaptive turns. The effective participant surface selects the runtime: text, push-to-talk, and continuous voice use turn-based collection; an eligible widget/voice participant with config.style: "live" uses one persistent Gemini Live conversation.

Tip: Keep media settings on the participant and behavioral settings on the Agent. The Agent's final schema-valid result is written under its node ID exactly once, regardless of which converse runtime executes. Persistent Live converse is rollout-gated and may validate with a warning while the turn-based runtime remains active.

Agent reliability controls

Agent nodes support ordered fallback_models, model reasoning controls, per-call tool_choiceand tool_timeout_ms, plus cumulative token, tool-call, and iteration limits. The model catalogue identifies structured-output, reasoning, caching, and vision capabilities so unsupported controls are explicit in the editor.

json
{
  "type": "Agent",
  "id": "review",
  "model": "openai/gpt-4.1",
  "fallback_models": ["anthropic/claude-sonnet-4-5"],
  "memory": { "thread": "sales_review" },
  "limits": { "max_total_tokens": 20000, "max_tool_calls": 12, "max_iterations": 8 },
  "tool_choice": "auto",
  "tool_timeout_ms": 15000,
  "output_schema": {
    "type": "object",
    "properties": { "summary": { "type": "string" } },
    "required": ["summary"]
  }
}

Named memory threads are scoped to one run and must be dot-free. Delegate to a registered agent with {"agent":"name"}in tools. Use run_workflowwhen delegation must survive suspension or collect participant input; the parent resumes without replaying completed tools.

Attachments & multimodal input

Add images or documents to an Agent turn with anattachments array. Each entry must be one complete artifact/URL template reference or an absolute HTTP(S) URL. Save-time validation rejects known models withoutsupports_vision; unknown catalogue entries are checked again at runtime.

json
{
  "type": "Agent",
  "id": "inspect_receipt",
  "model": "openai/gpt-4o",
  "attachments": ["{{upload._artifact}}"],
  "input": "Extract the merchant, date, and total."
}
ProviderImage partsDocument/file parts
OpenAIImages supported; file parts are encoded by the provider adapter when supported by that model/API.
AnthropicImages and supported document parts are encoded as native content blocks.
GeminiImages and files use inline_data or file_data content parts.
OllamaCatalogued text models reject attachments; compatible image models use the provider image sidecar, while file parts fail clearly.
Warning: supports_vision is the single Chatterfly attachment flag, not a promise that every provider accepts every file type. Provider adapters reject unsupported document parts rather than converting them silently to prompt text.

Connection bindings

Tool and Agent nodes reference a stable requirement slug, such ascrm, instead of an environment-specific connection. Bind that slug to a concrete connection when you deploy. Native providers such as Zoho expose only the operations enabled by the connection's selected capabilities and granted scopes.

json
{
  "type": "Tool",
  "id": "create-lead",
  "tool": "crm_create_lead",
  "connection": "crm",
  "arguments": { "data": [{ "Last_Name": "{{review.last_name}}" }] }
}
Provider writes: Collect and validate data first, show a review, and require explicit participant confirmation before a mutation Tool node. User-attributed Zoho writes require a private deployment and caller or participant authority. See the Zoho connection guide.

Validation

Input nodes (and declared run inputs) accept deterministic validation rules: required, min / max, min_length / max_length, pattern, email, url, phone, location, file rules (file_size, mime_type, duration), and an llmrule for semantic checks (“must be a plausible company name”). Rules compose with and / or / not.

Tip: Failed validation re-prompts the participant with the rule's message instead of failing the run.

Complete example

json
{
  "type": "Workflow",
  "id": "feedback",
  "participants": [
    { "name": "respondent", "bind": "run_initiator",
      "identity": { "anonymous": true }, "surface": "widget" }
  ],
  "children": [
    { "type": "Message", "id": "welcome",
      "content": "Thanks for trying us! Two quick questions." },
    { "type": "Input", "id": "score", "participant_role": "respondent",
      "prompt": "How likely are you to recommend us?",
      "input_type": "Rating", "variant": "nps" },
    { "type": "Conditional", "id": "branch",
      "condition": { "field": "score", "op": "lte", "value": 6 },
      "then": [
        { "type": "Input", "id": "why", "participant_role": "respondent",
          "prompt": "Sorry to hear that — what went wrong?",
          "input_type": "TextArea" }
      ],
      "else": [
        { "type": "Message", "id": "yay", "content": "Glad you like it!" }
      ]
    },
    { "type": "Agent", "id": "summarise",
      "prompt": "Summarise this feedback: score {{score}}, comment {{why}}" }
  ]
}

Deploy this with Public access, and the Quickstart steps put it live on a page.