.md

Workflow template reference

A workflow is one JSON file in your project, workflows/<key>.workflow-v3.json. The editor reads and writes it, and dcupl workflow deploy sends it to a runner. This page lists the fields of that file, the globals a script node runs with, how {{…}} templates in node settings resolve, how to call a deployed workflow, and the limits a run works within.

The -v3 in the file name, the TemplateV3 schema name and the "version": "3" field inside the file are the template format version. Versions 1 and 2 are legacy step-pipeline formats (steps[]); new workflows use the node-graph format, format version 3.

For the exact config fields of one node type, ask the CLI: dcupl schemas get <Schema> prints the schema the template is validated against (the schema names are in Node types), and dcupl schemas get TemplateV3 --example prints a complete template.

The template file

Field Required Value
key yes The workflow key. Deploying prefixes it with your project ID, see Calling a deployed workflow
config yes The graph: { "version": "3", "nodes": [...], "edges": [...] }
name no Display name, also used in reporting messages
description no Shown in the console
version no A version label of your own
variables no Template variables, see Variables
ui no Editor layout: positions (node id → { x, y }) and notes (canvas notes). The runner ignores it

Nodes

Every entry of config.nodes has these fields:

Field Value
id Unique within the template. Edges and ui.positions refer to it
name, description Shown in the editor
nodeClass trigger, step or response
type The node type, see below
inputs Input ports, { "type": "main", "displayName"?, "required"? }. Triggers have none. With required: true the node is skipped when no item arrives
outputs Output ports, { "type": "main", "displayName"? }. Responses have none. An edge can only use a port the node declares
config The settings of the node type
merge Step nodes only: how the items of several incoming edges are combined, see Merge Strategies. Needs at least two input ports
skip Step nodes only: true skips the node, so it runs nothing and sends nothing on

Node types

Trigger nodes (nodeClass: "trigger"):

type Main config fields
trigger-request path, methods (GET, POST, PUT, DELETE, PATCH), auth
trigger-cron cronTime (5 fields, or 6 with seconds), timezone (default UTC)

Step nodes (nodeClass: "step"):

type Main config fields
request url, method, headers, body, autoParse (default true), responseType
script script
data-mapper mappings ({ origin, target }), model
dcupl-instance script, processOptions, loaderFetchOptions, initOptions (config.apiKey: a project API key uid, see below), models
dcupl-files auth.apiKey, version, files (action: read, write, delete, move, copy)
s3-files auth (accessKeyId, secretAccessKey, region), bucket, files
azure-files auth.connectionString, containerName, files
git-files auth (user, token), repository (user, repo, branch), message, files
dcupl-api operation: email, digest or executions, see The dcupl API node

Response nodes (nodeClass: "response"):

type Main config fields
response-script script, headers, see Script Response
response-status none: config is {}

The schema names for dcupl schemas get are TriggerRequestConfig, TriggerCronConfig, ResponseScriptConfig, ResponseStatusConfig, and for the steps the type plus StepConfig: RequestStepConfig, ScriptStepConfig, DataMapperStepConfig, DcuplInstanceStepConfig, DcuplFilesStepConfig, S3FilesStepConfig, AzureFilesStepConfig, GitFilesStepConfig. The dcupl API node has no schema in the CLI.

The output ports the editor creates: main for triggers; main, error, client-error, server-error and timeout for a Request node; main, error, not-found, permission-denied and already-exists for the file nodes; main, error and permission-denied for a dcupl API node; main and error for the other steps, plus any ports you add to a Script node.

The code of a script, dcupl-instance or response-script node is the string in config.script, not code.

A trigger's auth is a list of { "type": "api-key", "value": "<api-key-uid>" }. In the file, value is the uid of a project API key (Project Settings → API Keys); deploying resolves it to the key, which callers then send. Without auth, or with an empty list, the trigger is open.

The same resolution applies to a dcupl-instance node's initOptions.config.apiKey and to the file nodes' credentials (dcupl-files auth.apiKey, git-files auth.token, azure-files auth.connectionString, s3-files auth.accessKeyId and auth.secretAccessKey): a value that is the uid of a project API key is replaced with the key when you deploy, and any other value is kept as it is. A dcupl-instance node added in the editor gets the uid of the project's Workflow Execution Key in initOptions.config.apiKey when the project has one (the oldest projects have none, and the node then starts with no key); a private project, which every new project is by default, needs a key there for the node to load its loader. {{…}} placeholders are not filled in inside initOptions.

Edges

Field Value
id Unique within the template
source { "nodeId", "port" }: an output port of the source node
target { "nodeId", "port" }: an input port of the target node, usually main

An edge also accepts condition and transform, but the runner ignores both, see Edge Conditions and Transforms.

Graph rules

dcupl workflow validate checks these locally, before anything is deployed:

  • Node ids are unique, and edge ids are unique.
  • There is at least one trigger. Triggers have no input ports and no incoming edges; response nodes have no output ports and no outgoing edges.
  • Every edge names nodes that exist and ports those nodes declare.
  • merge needs at least two input ports on its node, and merge-by-key needs a key.
  • A Request node's url is a valid URL or contains a {{…}} template.

With --runner <uid>, the console API validates the template too: it checks for cycles, for the project's node and edge limits, and that no two request triggers handle the same path and method. A node without any edge gives a warning.

Variables

A template variable is { "name", "value", "description" }. variables can also be a flat map of string values, { "name": "value" }, which is the form the console deploys and the runner reads. When you deploy, the runner gets one value per name, from (highest priority first):

  1. Deployment overrides (Deploy with variables…)
  2. Project variables (Global Workflow Variables)
  3. The template's variables

Values are resolved at deploy time: after changing one, redeploy. Read them with {{variables.name}} in node settings and with $context.variables.name in scripts.

Templates in node settings

The string settings of Request, file and dcupl API nodes (a URL, a header, a body, a file path, file content, a mail subject) can contain {{…}} placeholders. They are filled in once for each input item:

Placeholder Value
{{$json.field}} A field of the input item's JSON; a dotted path reaches nested fields ({{$json.address.city}})
{{$json}} The whole input item's JSON, as a JSON string
{{variables.name}} A workflow variable. The name is read as is: {{variables.a.b}} looks up a variable called a.b
{{request.body.x}} The trigger request: request.body.…, request.query.…, request.headers.…, request.params.…, request.method
{{$state.key}} A value an upstream script stored with $state.set(); {{$state}} is the whole store as JSON

A value is inserted with String(), so an object becomes [object Object]; use {{$json}} to send a whole object. A placeholder that does not resolve raises Unresolved template placeholder {{…}}; it is never left in the text or replaced with an empty string. What the node does with that error depends on its type:

  • Request, dcupl Files and Git Files: the whole node fails. One error item goes to its error port and the results of its other items are dropped; without an error edge the run fails.
  • S3 Files and Azure Files: that input item yields an item { error, files: [], ok: false } on main, and the other items go on.
  • dcupl API: that input item yields an error item on the error port, and the other items go on. A field of the request body is {{request.body.x}}, not {{request.x}}.

Script globals

A script node's code is the body of an async function that runs in an isolated JavaScript sandbox. It gets the node's input items and these globals:

Global What it gives you
$json() The JSON of every input item, as an array
$json.first(), $json.last(), $json.at(i) The JSON of one input item, or null when there is none
$json.fromPort(name) The JSON of the items that arrived on input port name
$items(), $items.first(), .last(), .at(i), .fromPort(name) The same as whole items, { json, pairedItem }
$input.ports(), $input.hasPort(name), $input.count(), $input.countByPort(name) Which input ports received items, and how many
$context { executionId, workflowKey, nodeId, variables, request }; request is the trigger request (method, url, headers, query, params, body)
$state A store shared by the script nodes of one run: get(key, fallback?), set(key, value), has, delete, clear, keys, values, entries. Values must be JSON
$ports(map), _output.route(map) Send items to named output ports, see below
console.log, .info, .warn, .error, .debug Captured only when the run is traced (Enable Data Trace in a test run); otherwise they do nothing

$json is a function: $json.title is undefined, $json.first().title is the field. The item a Request node emits holds the response in body ($json.first().body), next to headers, statusCode, statusText, contentType and ok. The item a request trigger emits holds method, headers, query, params and body.

The helpers are namespaced objects, not bare functions:

Helper Functions
_csv toJSON, fromJSON, headers, validate, parseCsvLine, escapeCsvField
_json toCSV, distinct, groupBy, flatten, unflatten, merge, pick, omit, parse, stringify
_array chunk, unique, flatten, groupBy, sortBy, pluck, sum, avg, min, max, intersect, difference, sample, shuffle
_string slugify, capitalize, camelCase, snakeCase, kebabCase, truncate, template, stripHtml, escapeHtml, unescapeHtml, toBase64, fromBase64, pad, reverse, count, startsWith, endsWith
_object get, set, has, merge, clone, pick, omit, mapKeys, mapValues, invert, keys, values, isEmpty
_datetime now, toISO, format, parse, add, subtract, diff, isBefore, isAfter, isSame, startOf, endOf
_xml parse, toJSON, stringify, fromJSON, validate
_output route, create, partition, split, main, error, success, isValid
_nodes dcuplFiles, gitFiles, s3Files, azureFiles, request, each with prepare(ops), which returns the operations as items for a downstream node

_http and _crypto exist, but every call throws: fetch with a Request node, and keep secrets in workflow variables. _json.toCSV, _string.toBase64 and _string.fromBase64 also throw in a script node; build CSV with _csv.fromJSON. The Console's script editor completes the globals and helpers, and its snippets show an example for each.

The sandbox has the standard JavaScript built-ins (JSON, Math, Date, Promise, …) and nothing from Node.js or the browser: no fetch, no require, no process, no Buffer, no timers.

What a script returns

The script… Output
returns an array One item per entry on main
returns an object One item on main, the object is its JSON
returns a primitive (42) One item { "value": 42 } on main
returns nothing (undefined, null) The input items, unchanged, on main
returns $ports({ … }) or _output.route({ … }) Each key is a port: an array becomes one item per entry, a single value one item
throws The error goes to the node's error port

A plain object such as return { valid: [...], invalid: [...] } is data, not routing: it lands on main as one item. Items on an unconnected main port just end the branch. Items on any other port without an outgoing edge are replaced by one generated error item on the error port, and if error has no edge either, the run fails. To connect a port, declare it in the node's outputs (+ Add Output on the node's Ports & Schema tab).

Route items to two ports
const products = $json.first().body.products || [];

return _output.route({
  valid: products.filter((p) => p.price > 0),
  invalid: products.filter((p) => !(p.price > 0)),
});

A script that runs longer than the script timeout fails with Script execution timed out after 500ms (see Limits).

Script Response

A Script Response node runs its script with the same globals, plus four shortcuts: data (the same as $json()), executionId, variables and request. What the script returns is the HTTP response body:

  • Return an object. Its fields are the response; an array is spread into an object with the keys "0", "1", …, so wrap it: return { products: data };.
  • To set response headers, return { "data": …, "headers": { … } }: data becomes the body and headers are added to the node's configured headers.

Calling a deployed workflow

Deploying registers the workflow on the runner under <projectId>-<key>. A runner set up without the project prefix uses the bare key; the Workflows page shows the deployed key. A request trigger answers at:

{runner-url}/v3/workflow/{deployed-key}/{path}

{path} is the trigger's path without its leading slash. A segment written :name in the trigger's path matches one path segment, which reaches the run as request.params.name. A request whose method or path matches no trigger gets 404 with the list of availableTriggers.

If the trigger has auth, send one of its keys in the api-key header or in the api-key (or apiKey) query parameter. A missing or wrong key, and a deployed key the runner does not know, are answered with 403.

curl -X POST 'https://<runner-url>/v3/workflow/<project-id>-product-sync/products/sync' \
  -H 'api-key: <api-key>' \
  -H 'content-type: application/json' \
  -d '{ "limit": 5 }'

The response

The run ends in… Response body
a Script Response node What the script returns. A failed run answers { "error": { "message", "detail", "errorType", "nodeId" } }
an HTTP Response node { "status": "completed", "executionId", "duration" }. A failed run answers "status": "failed" and an error with message and detail
no response node { "status", "executionId", "data", "metadata": { "duration", "nodesExecuted", "itemsProcessed" } }; data holds the items of the nodes that end a branch

A failed run is answered with 504 when it timed out, 507 when it ran out of memory, and 500 otherwise. A run that reaches its time limit is asked to stop and keeps its response node's failure shape: { "error": { "message", "detail", "errorType": "TimeoutError", "nodeId" } } for a Script Response, { "status": "failed", "executionId", "duration", "error" } for an HTTP Response. The runner kills a run that runs out of memory, or that has not stopped 3 s after its time limit: a killed run answers in the shape of a run without a response node ("status": "failed", "data": [], an error and metadata), even when it has a response node. The response of a synchronous run carries the run's id in the X-Dcupl-Execution-Id header.

The response comes from the first node in the template's nodes list that ends a branch with items. In a graph with a response node and another branch that ends in a step node, let that step return [], or its items may become the answer instead.

Asynchronous runs

Add ?async=true to answer at once instead of waiting for the run:

{
  "jobId": "abc123xyz456",
  "status": "queued",
  "workflowKey": "<project-id>-product-sync",
  "statusUrl": "…/v3/workflow/<project-id>-product-sync/jobs/abc123xyz456/status",
  "eventsUrl": "…/v3/workflow/<project-id>-product-sync/jobs/abc123xyz456/events",
  "createdAt": "2026-10-13T09:00:00.000Z"
}

Poll GET {runner-url}/v3/workflow/{deployed-key}/jobs/{jobId}/status with the same key as the trigger. It answers { jobId, status, workflowKey, executionId, createdAt, startedAt, completedAt, result, error, metadata }, where status is queued, running, completed, failed or cancelled and result holds the run's result once it is completed. DELETE …/jobs/{jobId} cancels a job, and GET …/jobs/{jobId}/events streams its progress as server-sent events. The runner keeps a finished job for one hour.

Past runs, synchronous or not, are listed in the editor's History and by dcupl workflow history, with the status running, completed, failed or cancelled.

Limits

A run works within these limits. The time, memory, node, edge and deployed-workflow limits are project settings that dcupl can raise for a project; the others are fixed.

Limit Default
One run, from trigger to response 10 s
One HTTP call of a Request node 5 s
One script of a Script, Script Response or dcupl Instance node 500 ms
Memory of a run 128 MB
Nodes per workflow 100
Edges per workflow 100
Items on one edge 1,000,000
Deployed key (<projectId>-<key>) 4–49 characters of a-z, A-Z, 0-9, _ and -
Deployed workflows per project 1

The console's create form allows only lowercase letters, numbers and hyphens in the key. The console API refuses a key with /, a backslash, ?, #, % or a control character when you deploy. Any other character outside the set in the table deploys, but the runner doesn't load the workflow, and calls to it get 403. The Workflows page shows the count as Deployed: n/max. A workflow counts once per runner it is deployed to, and redeploying it to the same runner doesn't add one; past the limit, a deploy fails with V3 workflow limit reached: <n>.

Accepted but not used

The schema accepts two edge fields that the runner does not act on. Leave them out:

The following fields are no longer part of the schema, because the runner never read them. A stored template that still has them is accepted and the fields are dropped:

  • config on an edge (its only field was bufferSize): an edge holds up to 1,000,000 items, whatever it says
  • executionMode and batchSize on a node: every node processes its whole input at once
  • merge.timeout
  • config.catch, a workflow-wide error node: route errors through each node's error port instead

Example

The workflow of Getting started as a template file. It passes dcupl workflow validate:

workflows/product-sync.workflow-v3.json
{
  "key": "product-sync",
  "name": "Product Sync",
  "variables": [
    { "name": "productsApiUrl", "value": "https://dummyjson.com/products", "description": "Product API base URL" }
  ],
  "config": {
    "version": "3",
    "nodes": [
      {
        "id": "trigger",
        "name": "HTTP Trigger",
        "nodeClass": "trigger",
        "type": "trigger-request",
        "inputs": [],
        "outputs": [{ "type": "main" }],
        "config": { "path": "/products/sync", "methods": ["POST"] }
      },
      {
        "id": "respond",
        "name": "Return Products",
        "nodeClass": "response",
        "type": "response-script",
        "inputs": [{ "type": "main" }],
        "outputs": [],
        "config": { "script": "return { count: data.length, products: data };" }
      },
      {
        "id": "fetch",
        "name": "Fetch Products",
        "nodeClass": "step",
        "type": "request",
        "inputs": [{ "type": "main" }],
        "outputs": [
          { "type": "main" },
          { "type": "error" },
          { "type": "client-error" },
          { "type": "server-error" },
          { "type": "timeout" }
        ],
        "config": {
          "method": "GET",
          "url": "{{variables.productsApiUrl}}?limit={{request.body.limit}}&select=title,price,stock"
        }
      },
      {
        "id": "validate",
        "name": "Validate Products",
        "nodeClass": "step",
        "type": "script",
        "inputs": [{ "type": "main" }],
        "outputs": [{ "type": "main" }, { "type": "error" }, { "type": "valid" }, { "type": "invalid" }],
        "config": {
          "script": "const products = $json.first().body.products || [];\nconst isValid = (p) => Boolean(p.title) && p.price > 0 && p.stock >= 50;\nreturn _output.route({\n  valid: products.filter(isValid),\n  invalid: products.filter((p) => !isValid(p)),\n});"
        }
      },
      {
        "id": "transform",
        "name": "Transform Products",
        "nodeClass": "step",
        "type": "script",
        "inputs": [{ "type": "main" }],
        "outputs": [{ "type": "main" }, { "type": "error" }],
        "config": {
          "script": "return $json().map((p) => ({ id: p.id, name: p.title, price: p.price, stock: p.stock }));"
        }
      },
      {
        "id": "log",
        "name": "Log Errors",
        "nodeClass": "step",
        "type": "script",
        "inputs": [{ "type": "main" }],
        "outputs": [{ "type": "main" }, { "type": "error" }],
        "config": {
          "script": "$json().forEach((p) => console.log('low stock', p.id, p.title, p.stock));\nreturn [];"
        }
      }
    ],
    "edges": [
      {
        "id": "e1",
        "source": { "nodeId": "trigger", "port": "main" },
        "target": { "nodeId": "fetch", "port": "main" }
      },
      {
        "id": "e2",
        "source": { "nodeId": "fetch", "port": "main" },
        "target": { "nodeId": "validate", "port": "main" }
      },
      {
        "id": "e3",
        "source": { "nodeId": "validate", "port": "valid" },
        "target": { "nodeId": "transform", "port": "main" }
      },
      {
        "id": "e4",
        "source": { "nodeId": "validate", "port": "invalid" },
        "target": { "nodeId": "log", "port": "main" }
      },
      {
        "id": "e5",
        "source": { "nodeId": "transform", "port": "main" },
        "target": { "nodeId": "respond", "port": "main" }
      }
    ]
  }
}