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. Converses with a participant or works autonomously, can call tools, and extracts structured values into state.
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.
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.
CustomEscape hatch for custom step implementations.

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." } ]
}

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, enable survey mode, and the Quickstart steps put it live on a page.