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. 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. |
| 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. |
| Parallel | Runs independent durable branches concurrently and joins after a success quorum. |
| 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. |
| CreateDocument | Builds a downloadable document (PDF, HTML, Word, Markdown…) from content for later download. |
| ShowDocument | Shows a document to a participant with an inline preview and download buttons. |
| Custom | Escape hatch for custom step implementations. |
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.
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." } ]
}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.
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.
{
"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.
{
"type": "Agent",
"id": "inspect_receipt",
"model": "openai/gpt-4o",
"attachments": ["{{upload._artifact}}"],
"input": "Extract the merchant, date, and total."
}| Provider | Image parts | Document/file parts |
|---|---|---|
| OpenAI | Images supported; file parts are encoded by the provider adapter when supported by that model/API. | |
| Anthropic | Images and supported document parts are encoded as native content blocks. | |
| Gemini | Images and files use inline_data or file_data content parts. | |
| Ollama | Catalogued text models reject attachments; compatible image models use the provider image sidecar, while file parts fail clearly. |
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.
{
"type": "Tool",
"id": "create-lead",
"tool": "crm_create_lead",
"connection": "crm",
"arguments": { "data": [{ "Last_Name": "{{review.last_name}}" }] }
}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 with Public access, and the Quickstart steps put it live on a page.
