#Workflows
A workflow is an ordered set of jobs; each job runs a sequence of actions (HTTP calls, entity reads/writes, file processing, LLM inference, …). Workflows react to triggers — an entity event, a webhook, a cron tick, or a direct dispatch. See Core Concepts for the mental model.
A workflow lives in two layers:
- Definition — the design-time spec: the jobs, actions, and triggers.
Authored under
/definition/*; other tenants get it through a manifest you offer them. - Activation — a tenant turning a definition on: its env, trigger secrets,
bound policy, and the runs it produces. Driven under
/workflows/*.
Authenticate with a Personal Access Token (HTTP Basic auth) — enter it once in the Authorize bar at the top of this page and every Try it below uses it. Each example body is pre-filled; the console targets staging by default. Full request/response schemas are in the linked API reference.
#Build a workflow
The flow is: define the workflow → activate it → trigger a run → watch it.
#1. Define the workflow
A workflow binds triggers to job steps. Author each step's logic
inline — embed its actions directly in the step. Inline is the
recommended default: one definition, one version, nothing else to publish or
keep in sync. Each action has a stepId, a name (from the
action catalog), and a config; see
jobs & actions for the conditional fields (if, errorWhen,
errorPolicy, onError). dependencies order steps into a DAG.
POST/definition/workflowsCreate a workflow (inline job)API docs ↗Try it
Three details make this example work. The step's input hands the job the Task
— from an entity event it is context.source, from a dispatch or webhook it is
context.input — because an action sees only input and outputs. The body
and payload are objects, so they use $jsonata: a whole-string {{ … }}
produces a string. And flag-failure restates the Task's attributes and sets
suppressEvents, so its own write does not fire on-task-updated again. Add
notify_failed (a boolean) to the Task's attribute schema — an attribute the
schema does not declare is not stored.
A definition is draft until the first activation flips it to active. The
write is validated against the workflow JSON schema; per-action config is
validated again at run time, after smart values resolve. Pass source_yaml
instead of definition_json to author in YAML.
Reuse the same actions across workflows? Publish them as a job definition (
POST /definition/jobs) and reference it from a step by name:{ "stepId": "remind", "name": "notify-overdue-task" }. The step names a key, not a version — publishing binds it to your latest active job of that name, and a binding can point it elsewhere per tenant. Reach for a referenced job only when you genuinely need to share one action sequence across several workflows, or to let each environment run a different job behind the same step — otherwise keep it inline.
#2. Activate it
Activation is the consumer tenant turning the definition on: it merges env,
reconciles trigger rows, binds the access policy the run acts under when you
pass policy_id or policy, and (for cron) provisions schedules. It is
idempotent — re-activating with the same body produces the same state.
POST/workflows/{id}/activateActivate a workflow in your tenantAPI docs ↗Try it
The response returns each enabled trigger with its config_status, plus a
webhook's webhook_url (what external callers POST to) and a cron's schedule.
Cron and webhook-secret details are under Triggers below.
Env is a merge. The definition supplies defaults under config.env; the
tenant's activation (and later PUT /workflows/{id}/config) overlays its own
values on top — a key you send is set, a key sent as null is unset, and a key
you leave out keeps what it had. GET /workflows/{id}/config shows the
effective env.
Declare configSchemas.env on the definition to name a JSON schema the
effective env must satisfy. It is checked on every config write, and the same
validation runs again on every run, where a failure refuses the run with 400.
A tenant in another account needs the schema as a json_schema manifest member.
Details: Configuration & Config Schemas.
#3. Trigger a run
Four sources fire a run: an entity event, a webhook POST, a cron
tick, or a direct dispatch. Direct dispatch is the ad-hoc path — pass the
trigger's definition id (e.g. "manual"), not the row UUID:
POST/workflows/wrDispatch a runAPI docs ↗Try it
Returns 202 with the created run (its id is the run id). input reaches the
run as context.input; here it has the shape of a Task, so the remind step
treats it exactly like an entity event. Webhook callers instead POST to the
webhook_url from activation; cron and event triggers fire automatically.
To dispatch a run, the caller needs read on the workflow_definition and
create and read on workflow_run, each granted on the workflow's id.
Without read on the definition the dispatch is a 404; without both run
actions no run is created. The stock workflow_executor role carries all three
for every workflow; for a least-privilege token, grant exactly these (see
Provisioning).
#4. Monitor the run
List runs (filter by workflow_id, status_in, entity_id, …), then drill
into one for its per-job and per-action errors.
GET/workflows/runs/workflowsList recent runsAPI docs ↗Try it
GET/workflows/runs/workflows/{id}Inspect a run (job + action detail)API docs ↗Try it
When a run fails, its own error carries the failure that ended it; per-step
failures live on job_runs[].error, and each action's on
job_runs[].action_log[].error. A step that declares an output expression
records its result on job_runs[].output.
A run moves pending → running → one of five terminal states: success,
error, cancelled, skipped, or timeout. Poll for membership of that
terminal set rather than for a particular value — skipped is how a run gated
by a concurrency or rerun rule ends, and it is a normal outcome, not a failure.
A run whose step is waiting on an awaitInput callback stays running; the
waiting shows on the action, not on the run.
#5. Retry or cancel
Retrying creates a new run with the same input, linked to the one it retries
by original_workflow_run_id and numbered by attempt_number. Only the latest
attempt of a chain can be retried; retrying an earlier one is a 400
(Only the latest attempt can be retried.).
POST/workflows/wr/{workflowRunId}/retryRetry a runAPI docs ↗Try it
Cancelling ends an unfinished run as cancelled. Cancelling a run that has
already finished changes nothing and returns it as it is.
POST/workflows/wr/{workflowRunId}/cancelCancel a runAPI docs ↗Try it
Runs of one workflow execute one at a time by default. The definition's
controls.concurrency.workflow is { "limit": 1, "policy": "queue" } unless
you set it: a run that arrives while limit runs are in flight waits its turn
(queue) or ends skipped (skip). Raise limit for workflows whose runs are
independent of each other.
#Jobs & actions
Inline and referenced jobs run identically — both resolve to the same shape
before execution. Prefer inline (actions directly on the workflow step):
it keeps a workflow self-contained, with one definition to manage. Reach for a
referenced job definition (POST /definition/jobs) when you need to share
one action sequence across several workflows, or to let each environment run a
different job behind the same step.
A reference step names a key, not a job: it carries name and no actions,
and which job that key runs is a separate decision — a binding — made per
definition, per activation, or per integration. Publishing a workflow fills each
unbound name from your own latest active job of that name, so the common case
needs no extra step; pinning something else, or something different per tenant,
is a binding write. See Job Bindings.
#Input and output contracts
A job, and a trigger, may declare the shape it accepts — and a job the shape it produces — as a JSON schema written into the definition:
{
"inputSchema": {
"type": "object",
"properties": { "order_id": { "type": "string" } },
"required": ["order_id"]
},
"outputSchema": {
"type": "object",
"properties": { "sent": { "type": "boolean" } }
},
"actions": [/* … */]
}
An embedded contract travels with the definition, so a job shared to another
tenant validates there without anyone registering a schema first. $ref is
allowed only within the same document — a contract never depends on something
outside it.
Alternatively, inputSchemaName / outputSchemaName name a registered
catalogue schema. Declare one or the other: a definition carrying both forms
for the same contract is refused at publish, so there is never a question of
which one applied.
#Step-level control (per job step)
A workflow job step accepts a condition gate and error/rerun policies:
| Field | Values | Meaning |
|---|---|---|
dependencies |
string[] |
Step ids that must finish first (defines the DAG). |
condition |
bare JSONata | Evaluated against the step scope below; if false, see conditionPolicy. |
conditionPolicy |
continue | abort | fail |
On false condition: continue skips the step, abort ends the workflow as success, fail ends it as error. Default fail. |
errorPolicy |
continue | abort | fail |
On step error: same three outcomes. Default fail. |
rerunPolicy |
continue | allow | abort |
On a parent retry: reuse prior output, re-run, or end. Default allow. |
#Action-level control (per action, inside a job)
Each action carries optional conditional and recovery fields. This is what lets a single job branch and self-heal without a separate step:
| Field | Values | Meaning |
|---|---|---|
if |
bare JSONata | Evaluated before the action. A falsy result skips the action and its onSuccess and moves on. Mirrors a bridge step's if gate. |
errorWhen |
bare JSONata | Evaluated after the action on its result; a truthy value marks the action failed (for http, defaults to result.status >= 400). |
errorMessage |
literal or {{ }} string |
The author's refusal text when errorWhen (or the default status check) fires. See below. |
onSuccess |
action sub-sequence | Actions run after this action succeeds, before the parent sequence continues. |
onError |
action sub-sequence | Recovery actions run after this action fails, before errorPolicy is applied. |
errorPolicy |
fail | continue |
After the action fails (and its onError runs): fail (default) ends the job with the error; continue consumes the error and resumes the parent sequence at the next action. |
{
"stepId": "find",
"name": "queryEntity",
"config": { "typeName": "Message" },
"errorWhen": "$count(result.data) = 0",
"errorMessage": "No message found for '{{ input.term }}'"
}
errorMessage resolves over { input, result, item, index }, is trimmed, and
is capped at 300 characters (a longer text is cut and ends in …). It applies
only when the predicate fires, never to an error a handler throws. The failure
the run then records — on the run's error, the step's job_runs[].error and
the action's action_log[].error — has status: 400, no code, and that text
alone as its message. The usual wording moves to details.rejection; an
HTTP-shaped result adds details.response_status and details.response_error
(the upstream's answer), any other result details.result:
{
"status": 400,
"message": "No message found for 'acme'",
"details": {
"rejection": "Action \"queryEntity\" returned an unacceptable result: {\"data\":[]}",
"result": { "data": [] },
"action_run_id": "3c1f0a52-6d1e-4b7a-9f20-8e5d4c3b2a19"
},
"action_details": {
"action_name": "queryEntity",
"action_run_id": "3c1f0a52-6d1e-4b7a-9f20-8e5d4c3b2a19",
"step_id_in_job": "find"
}
}
Its input and a loop's item are the values before secret resolution, so a
secret appears in the text only as its {{ secrets.* }} reference, never as its
value; an app scrubs that reference from the text it shows, as it does from a
step's output ([secrets reference removed]). A process before effect whose
job refuses this way refuses the move with that text: the move's answer carries
it as its message and as details.refusal_message, and an app shows it to the
person as written (the default notification and outputs.__error__.message).
Any other effect failure shows only its code's label.
Action
errorPolicy(fail/continue) is distinct from the job-steperrorPolicy(continue/abort/fail). The action field governs flow inside a job; the step field governs the workflow outcome when the whole step errors. In the workflow above, thenotifyaction setserrorPolicy: "continue"and anonErrorthat flags the task — so a failed notification is recorded and the job still completes.
Authoring fields like config, body, if, and errorWhen are smart
values: literals, {{ }} JSONata expressions (a whole-string {{ … }}
stringifies), or $-directives for raw values.
Two scopes, not one. A workflow step's fields (input, condition, the
idempotency key) see outputs (earlier steps), context (what started the run
— a dispatch or webhook payload is context.input; an entity event's entity is
context.source and its changes context.meta.diffs), and config.env. An
action's config, inside a job, sees only input and outputs — a run's
context never reaches it. Thread what an action needs through the step's input
and read it as {{ input.x }}. The full model — bindings, directives, and
failure modes — is in Smart Values.
To reshape a row set inline — filter, map, join, sort, or dedupe a prior
step's output before writing it — wrap it in a { $rows, $pipe } node in any
action config / input / output, or run the shaping as a named pipe
action ({ "rows"?: <array>, "$pipe": [ …steps ] } — rows in, rows out, no
reads or writes; rows defaults to the job's input). The $pipe vocabulary is
the same one apps use; see
Shaping a row set.
#Action catalog
The orchestration layer ships these action types. Every config is a smart
value; see the API reference for each action's exact config schema.
| Action | Kind | Purpose |
|---|---|---|
http |
external | Synchronous HTTP request; auth attaches credentials (see below) |
httpCallback |
external | Async HTTP request; resumes on callback |
llmCallback |
external | Async LLM inference (Anthropic / OpenAI) |
awaitInput |
workflow | Pause until an operator submits structured input |
invokeWorkflow |
workflow | Spawn a child workflow run (fire-and-forget) |
queryEntity |
entity | Read one or many entities (filter/sort/one-hop include, keyset cursor, distinctOn, countStrategy) |
queryEntityGraph |
entity | Read a deduplicated entity graph (nested include tree) |
upsertEntity |
entity | Create / update entities (single or bulk) |
pipe |
data | Reshape rows with $pipe steps (pure; no reads/writes) |
removeEntities |
entity | Delete entities by id |
upsertRelationship |
entity | Create / update relationship instances |
removeRelationships |
entity | Delete relationship instances by id |
syncRelationships |
entity | Set-replace one entity's links under a rule |
uploadArtifact / downloadArtifact / listEntityArtifacts / createArtifactDownloadURL |
artifact | Store, fetch, list, and sign artifact access. downloadArtifact with as: "text" reads the body into scope (text, content_type), capped by maxTextBytes |
uploadTempFile / downloadTempFile / createTempFileDownloadURL |
file | Store, read back, and sign access to a temp file — the entity-less counterpart of the three artifact actions |
zipCompress / zipDecompress / archiveDecompress |
file | Compress and extract archives |
splitPdf / mergePdf / chunkPdf |
file | Split, concatenate, and chunk PDFs |
csvFromZipStream / transformCsvStream / mergeCsvStreams |
csv | CSV extract, per-row transform, and join |
importEntitiesFromCsv |
csv | Bulk import entities from a CSV stream |
exportEntitiesToCsv / exportEntitiesToXlsx |
csv | Bulk export entities to a CSV stream or an XLSX workbook — see Bulk export actions |
awaitWorkflow |
workflow | Start another workflow and wait for it to end or for the deadline (CHILD_FAILED / CHILD_TIMEOUT) |
createEntitiesFromTempFiles |
entity | Turn up to 20 temp files into entity rows that carry them as artifacts, in one call |
getEntityTimeline |
entity | One page of an entity's recorded changes, newest first, read as the caller (reference) |
checkPermissions |
access | Up to 1,000 permission questions answered for the caller's session (reference) |
performTransition / performTransitions |
process | Move one process step, or up to 1,000 in one commit (Process Models) |
undoTransition / releaseHold |
process | Undo a step's move by its undo plan; re-plan held moves |
getProcessStatus / listProcessTransitions |
process | Where a page of anchors' steps stand; a step's move log, newest first |
describeProcessActions / describeProcessForm |
process | Each step's decided controls for a page of rows; a transition's form, defaults and evidence |
Calling an authenticated API. http and httpCallback take an auth field
instead of a hand-built Authorization header: name a profile declared once in
the definition's root auth map, or write the mechanism inline. Profiles,
mechanisms, and their errors are in
HTTP Auth & External Data.
Redirects. An http action follows redirects by default and re-checks every
hop's target; a hop to another origin carries no credentials. Set
redirect: "manual" to read the Location yourself. The rules are in
HTTP Auth & External Data → Network rules.
#Bulk export actions
exportEntitiesToCsv and exportEntitiesToXlsx run the same exports as
/entity/entities/export and /entity/entities/export-xlsx,
with the same fields in camelCase: typeName, filter, sort, limit,
columns, columnOrder, arrayStrategy, batchSize, outputFilename, plus
delimiter (CSV) or sheetName (XLSX). The file goes to a stream — name it
with outputStreamId — and the action returns
{ "stream_id", "filename", "content_type" }. A later action reads the stream
by its id, for example uploadTempFile to hand back a download link:
[
{
"stepId": "xlsx",
"name": "exportEntitiesToXlsx",
"config": {
"typeName": "Task",
"filter": {
"operator": "eq",
"path": ["attributes", "status"],
"value": "open"
},
"sort": [{ "path": ["attributes", "due_date"], "direction": "asc" }],
"columns": {
"Key": "attributes.key",
"Title": "attributes.title",
"Due": "attributes.due_date"
},
"columnOrder": ["Key", "Title", "Due"],
"sheetName": "Open tasks",
"outputStreamId": "tasksXlsx",
"outputFilename": "open-tasks.xlsx"
}
},
{
"stepId": "link",
"name": "uploadTempFile",
"config": {
"streamId": "tasksXlsx",
"filename": "open-tasks.xlsx",
"contentType": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"ttl": 300
}
}
]
Set columnOrder whenever the column order matters. columns is a JSON
object, and a stored definition does not keep object key order — without
columnOrder the columns can come out in a different order than you wrote them.
exportEntitiesToXlsx refuses an export over 16 384 columns, 250 000 cells, or
4 000 000 characters of cell text, and the action fails with that 400; use
exportEntitiesToCsv for anything larger. Both actions also run inside
app action steps.
#Triggers
A workflow's definition_json.triggers[] declares when it runs. Each entry
has an id plus exactly one of source (event), cron, or webhook. A
webhook trigger is also the handle for direct dispatch, as manual is above.
Triggers are reconciled on every activate. Optional condition (a bare JSONata
gate), contextData (shapes the run's starting context, which steps read as
context.*), and idempotencyKey apply to any type.
Two behaviors to know. A trigger
conditiongates event triggers only — it's evaluated when the entity event matches, over the entity as written (source, e.g.source.attributes.status = 'open') and what changed (meta.diffs). It reads them bare, not undercontext.: a condition that readscontext.sourceis never true, and the trigger never fires. A direct dispatch, webhook, or cron tick is already an explicit request to run, so the condition doesn't gate it. And a source-bound trigger (one with asourceblock) fires only from its entity event: dispatching it directly (or via webhook/cron) is rejected. To re-run over an entity, re-emit its event rather than dispatching the source trigger by hand.
#Event trigger
Fires when an entity is created, updated, or deleted — optionally narrowed to one entity, one relationship rule, or matching attribute values.
{
"id": "on-task-updated",
"source": {
"entityTypeId": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
"eventAction": "updated", // created | updated | deleted
"relationshipRuleId": "<uuid?>", // optional — only this rule
"entityId": "<uuid?>", // optional — only this entity
"attributeFilters": [
{ "operator": "eq", "path": ["status"], "value": "open" }
]
}
}
#Cron trigger
A standard five-field cron expression, always interpreted in UTC — there is no per-activation timezone, so write the expression in UTC and convert on your side if your operators think in local time.
{ "id": "nightly", "cron": "0 0 * * *" }
Two scheduling behaviours worth knowing. Each schedule carries a random 0–9
second offset, so a fleet of workflows on the same expression does not stampede
the platform on the tick. And a cron that always fires again within the hour
(* * * * *, */5 * * * *) is not retried on a failed tick: the next tick
does the same work, and retrying would queue ticks that later fire together and
create duplicate runs. Hourly and less-frequent crons are retried.
#Webhook trigger
External callers POST to the trigger's webhook_url (returned at activation).
The run receives the request as context.input =
{ "body", "query", "method", "headers" }. verification checks request
authenticity; challenge answers a validation handshake (e.g. Microsoft Graph)
without dispatching a run. Without verification, anyone who has the URL can
start a run — declare it for any URL you hand to another system.
{
"id": "inbound",
"webhook": {
"methods": ["POST"],
"verification": {
"location": "header", // header | body | query
"key": "x-hub-signature-256",
"method": "hmac-sha256" // exact | hmac-sha256 | hmac-sha1
},
"challenge": {
"enabled": true,
"queryParam": "validationToken",
"responseType": "text/plain"
}
}
}
The first activation of a trigger that declares verification mints its secret
and returns it once, as the trigger's secret (whsec_…) in the activation
response. From then on every POST /workflows/hooks/{triggerId} is checked, and
a missing or bad value is a 401. For hmac-sha256 / hmac-sha1, send the
lowercase hex HMAC of the raw request body, keyed with the whole whsec_…
string, either bare or as sha256=<hex>. For exact, send the secret itself.
Minting again rotates the secret: the new one is returned once, and the old one stops working immediately.
POST/workflows/{id}/triggers/{triggerId}/secretRotate a webhook secret (returned once)API docs ↗Try it
Revoking the secret (DELETE on the same path) shuts the webhook URL: it
answers 401 until you mint a new secret.
After activation, pause and resume a trigger without re-activating via
PUT /workflows/{id}/triggers/{triggerId}/disable and …/enable. A disabled
webhook answers 401 and a disabled event or cron trigger stops firing. A
disabled trigger stays disabled when you re-activate, and it is left out of the
activation response and of GET /workflows/{id}/triggers — keep its id to
enable it again.
#Policy & secrets
- Policy — an activation binds an access policy that authorizes everything the run does. See Provisioning.
- Secrets — wire scoped secret refs into
config.env; never put a ref in the definition. See Secrets. - Outbound credentials — declare
authprofiles that read those values and attach them tohttp/httpCallback. See HTTP Auth & External Data.
#Inline jobs without a workflow
Apps (bridge) can run the same action sequences inline — synchronously, with no workflow run — for request handling. See Apps (Bridge).
#Share a workflow with another tenant
Another tenant runs your workflow by accepting a manifest that lists it,
then activating it in its own tenant. List every resource the workflow needs in
the manifest — the workflow itself, the entity types and schemas it reads or
writes, and the schema named in configSchemas.env; jobs pinned in its
default_job_bindings come along without being listed. See
Share with Another Tenant (authoring side) and
Accept & Activate (consumer side); the underlying
model is in Shares & Grants.
#API reference
Every workflow, job, trigger, and run operation — with full request/response schemas and an interactive console — is in the API reference.