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.
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:
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:
{
"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.
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
| Type | What it does |
|---|---|
| Workflow | Container / group. Executes children sequentially; with ui: "form" it renders child Inputs as one simultaneous form. |
| Agent | LLM-backed step. Converses with a participant or works autonomously, can call tools, and extracts structured values into state. |
| Input | Asks a participant one question (Text, Number, Rating, FileUpload, Location…) and stores the validated answer. |
| Message | Sends a one-way message (plain or markdown) to a participant. No response expected. |
| Tool | Calls a built-in tool or a connection tool (slug.operation) with templated arguments. |
| Code | Runs a sandboxed JavaScript snippet with templated input — for transforms, scoring, glue logic. |
| Conditional | If/else branching over state values using condition operators. |
| Loop | Repeats its children while a condition holds. |
| Repeater | Iterates children over a collection (e.g. one pass per item). |
| WorkflowCall | Invokes another workflow as a sub-workflow, mapping inputs and outputs. |
| Delay | Pauses for a duration, or until an ISO 8601 timestamp / templated expression. |
| Editor | Collaborative document or image editing session with a participant. |
| Custom | Escape 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:
{{issue}} → value captured by the "issue" node
{{triage.urgency}} → field extracted by the "triage" agent
{{personal.name}} → value inside the "personal" groupWhen 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:
"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, orunbound. - 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 viaconfig.style(push_to_talk,continuous,live). See Surfaces & transports.
"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:
{
"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.
Complete example
{
"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.
