Building Workflows
The Orchestrator is a flow designer: a vertical spine of steps that runs top to bottom, a palette of every action on the left, a properties panel on the right, and the workflow's source, run log and problems in a drawer underneath. Build it once, publish it, and one HTTPS call runs the whole thing.
New to the product rather than to this page? Getting Started walks the whole path — sign in, connect a system, build, run, publish — without any of the source below.
The workbench
Five areas, and nothing hidden behind a mode:
Palette (left)
Every action, grouped the way the API reference groups them, with a search box (press / from anywhere) and your recently used and starred actions pinned above. Each row shows what that action costs. Drag one onto the canvas, or click it after clicking a +.
Canvas (center)
The workflow itself, laid out for you — you never place a card by hand. An intake at the top says what feeds this workflow; a card per step; a result terminator at the bottom saying which step's output the caller gets back. Every connector carries a + insert point, and the three structural blocks — a fan-out, a condition and a loop — draw as group boxes with the steps inside them. See lanes, conditions and loops.
Inspector (right)
Opens when you select a card and shows that step's parameters, its label, what happens if it fails, and whether it is the result step. This is where you bind one step's output to the next step's input — see below.
Drawer (bottom)
Source is the workflow as text — the canvas and the text are the same object, so an edit in either shows up in the other immediately. Run is the log of the last run, one entry per step, with the response each one returned. Problems lists every error and warning; click one to jump to the step it belongs to.
The command bar across the top carries the workflow name and its state on the left, and New / Load / Save / Publish / Run on the right. Run always shows what the run will cost before you click it.
Build one
- Add the first step. Drag an action from the palette onto the canvas, or click the + under the intake and pick one. The card lands on the spine with its required parameters already listed.
- Chain the next one. Drop an action onto an existing card, or on the + below it, and it is inserted after that card with its document input already bound to the card above — a PDF step dropped under a step that produces a PDF is wired for you. Nothing you have already set is overwritten.
- Fan out when one document has more than one destination. Drag Run in parallel from the top of the palette; each lane runs against the step above the split, not against each other, and a lane can hold several steps. The two other structural blocks live beside it: Condition for a yes/no fork and For each to repeat over a list. See Repeat, split and decide below.
- Send it somewhere. The dashed Choose a destination card opens a list of everything the platform can deliver to, grouped as rows, files, messages and work items — see choosing a destination. Picking one turns the card into a real step with its settings seeded and the step above already wired in.
- Run it. The cards light up in order and the Run tab fills in as each one answers. Nothing is billed for a failed run, and using the builder itself is free — only published webhook calls and direct API calls are metered.
- Publish it to get a webhook URL. See Webhooks & Integrations.
What starts a workflow
The card at the top of the canvas is the intake. Click it to see everything that can set this workflow going, and to attach one. A workflow can have several.
| Intake | What it is |
|---|---|
| Webhook / API call | The published workflow's own address. Always available once it is published;
the body you post arrives as $input. See
Webhooks & Integrations. |
| Web form | A form we host. Each submission calls the workflow, with the answers as
$input keyed by your own field names. |
| Schedule | A clock. Nothing has to call it — see below. |
| SharePoint watcher | A library we poll. A new file starts the workflow. |
| Query | A Dataverse or SQL query step in this workflow, promoted to be what feeds it — see below. |
A schedule: run it on a clock
Publish the workflow, open the intake, and type a cadence. Plain words work —
every 15 minutes, every 2 hours, hourly,
daily at 06:00, weekly on Monday at 06:00,
monthly on the 1st at 06:00 — and so does a five-field cron line such as
0 6 * * *. Times are UTC, everywhere, and the intake says so
rather than guessing at your time zone.
The chip on the intake then reads the cadence and when it next runs. You can pause a schedule, resume it, run it once by hand, or delete it, all from the same panel.
- A schedule calls the published address, so a run costs exactly what the same call would cost from anywhere else, appears in Usage the same way, and is refused the same way when the balance is short. Having a schedule costs nothing; a schedule that is not due costs nothing.
- It never catches up. If the service was asleep for two hours, an every-five-minutes schedule runs once when it wakes, not twenty-four times.
- It pauses itself when it is clearly broken. Five failures in a row suspends it, with the reason kept on the schedule. A workflow that is no longer published suspends it on the first attempt, because that will not fix itself. Being short of tokens never suspends it, because that does fix itself.
A query: rows as the input
When a workflow is really "read these rows and do something with each of them", make the
query itself the intake. Pin a Dataverse or SQL query step as the intake and its rows
become $input for everything after it — so the rest of the workflow reads
$input.rows, the same name a form-fed workflow uses, and a loop can iterate it.
(pipeline "Example: Nightly Account Report"
:intake "read-accounts"
(step "read-accounts"
:label "Query the accounts"
(action "DataverseQueryRows"
:connectionId "REPLACE_WITH_CONNECTION_ID"
:entitySet "accounts"
:select "name,address1_city,telephone1"))
(step "render"
:label "Fill the report template"
(action "ComposeFill"
:templateId "REPLACE_WITH_TEMPLATE_ID"
:data $input.rows
:outputFormat "pdf"
:filename "Nightly report")))
Such a workflow accepts an empty call: there is nothing to post, because the workflow fetches its own input. That is exactly what a schedule sends it. The query is an ordinary step of the workflow and is billed once, like any other.
Choosing a destination
A workflow minted from a web form — or a lane you have just added — starts with a dashed Choose a destination card. Opening it lists everything the platform delivers to, in four families:
| Family | Destinations | What it wants |
|---|---|---|
| Rows | Dataverse, SQL, SharePoint | The submission or an earlier step's rows, written as records. |
| Files | Azure Blob storage, S3, SFTP | The document the step above produced, plus a path to put it at. |
| Messages | Email, Azure Service Bus | A subject and a body, or one message; a document goes as an attachment rather than into the body. |
| Work items | Azure DevOps | A project and a work item type; the step above becomes the description. |
Each row shows what it costs before you click it, and the connection dropdown beside it only offers connections of the right type — naming the type it needs when you have none. Email has no dropdown at all: it goes out through the platform's own mail registration.
Picking one replaces the placeholder with a real step, seeds the settings that have an obvious answer, and wires the step above into it. Anything it cannot know — which project, which path — it asks for.
Building a query without typing syntax
A Dataverse or SQL query step carries two buttons above its settings, and both open as soon as you have picked a connection:
- Browse data shows the tables on that connection and the columns on a table, and fills in the table and the columns you tick.
- Build query is the filter, the sort and the page size, as boxes: column, operator, value, one row per condition, with a single and/or for the set. Columns are listed under both names — the friendly one you recognize and the logical one the query actually sends — and what will run is printed beside the form so you can see it.
There is nothing to learn and no query language to type; the filter travels as structured values, so no text of yours is ever spliced into a query. Two things it is strict about, and both say so on screen: a value has to match the type you declared for it, and a filter it cannot draw (a bracketed group inside a group) is refused rather than flattened — flattening one silently changes which rows you get.
Once a query step has run, its own columns are offered by name to every step below it, so the next step's settings can be picked rather than typed.
Using a step's output in a later step
This is the one thing worth reading before you build anything. A step refers to an earlier step's result by step id and field name:
(step "stamp"
(action "WatermarkPdf"
:pdf (output-of "convert" "pdf") ; the "pdf" field of the step whose id is "convert"
:watermark.text "CONFIDENTIAL"))
Pick it, do not type it
Select the step, find the parameter in the inspector, and choose the earlier step and the field from the list rather than typing the reference by hand. The list is built from what those steps actually produced the last time you ran the workflow, so it cannot name a field that does not exist. Typing the reference works too, and the Source tab always shows you exactly what you built — but a picked reference is a reference you cannot misspell.
Two response shapes, and how to tell which one you have
result object, and then the field path has to say so.
In one pipeline, both of these can be correct:
(output-of "read-the-file" "contentBase64") ; results at the top level
(output-of "update-the-zip" "result.zip") ; results nested under `result`
The reliable way to find out: run the step once and read what came back.
- Put the step on the canvas and click Run.
- Open the Run tab in the drawer and expand Raw response under that step.
- The keys you see there are the field names you write. If they sit inside
result, writeresult.<field>.
A step usually costs one token to run, and the beta's monthly allowance is generous enough that checking is cheap. It is certainly cheaper than the alternative below.
What a wrong path looks like
Nothing complains while you build: a field path is not checked when you publish, only when
the workflow runs. In the builder, the step turns red and the Run tab names the field it
could not find. Through a published webhook you get an HTTP 500 with
PIPELINE_ERROR. If you get one of those, open the workflow, click Run, and the
builder will tell you which step and which field.
Repeat, split and decide
A workflow is a spine of steps that runs top to bottom. Three things break that straight line, and all three are drawn the same way — a marker, a group box, and a join where the flow comes back together — so once you have read one picture you have read all three.
For each — run the same steps once per row
This is the one that turns a query into a batch. Put the steps that make one document inside the loop, point the loop at a list, and they run once for every item in it.
Two ways to add one:
- On a step that returns a list — a SQL or Dataverse query, a CSV parse — open the card's menu and choose For each row in this result…. The loop lands under that step with the list already chosen.
- Or drag For each from the top of the palette and choose the list yourself in the inspector.
(for-each "each-row"
:label "One letter per person"
:in (output-of "rows" "rows") ; the list — a query's rows, $input.rows, or a literal list
:as "row" ; names the current item: $row inside the loop
(step "letter" …)
(step "render" …))
Inside the loop you get two new references, and they exist nowhere else:
$row— the whole current item. Named by:as; without it the name is$item.$row.email— one field of the current item.$index— which item this is, counting from zero.
Each pass through the loop sees the world as it was before the loop started, plus its own earlier steps — never another item's. Row 2's email cannot be handed row 1's PDF.
The loop's own settings sit on its card: how many items at a time (one by default, up to eight — it will not stampede your database), at most how many items (1,000 by default, 10,000 at the very most), and what happens if one item fails — carry on with the rest, which is the default, or stop at the first failure.
What comes out is stored under the loop's own id: one entry per item, in row order,
with an empty slot where an item failed, plus the list of failures. A later step reads
it the same way it reads any other step —
(output-of "each-row" "items"). The steps inside the loop are not
addressable afterwards: a step that ran 300 times has no single result to name.
If some items fail the run finishes: HTTP 200, with
loops in the response naming every failed row and why. If every
item fails the run did nothing it was asked to, so it answers HTTP 422
with FOR_EACH_ALL_FAILED — the same loops list, plus the
first row’s reason in the message. A whole batch usually fails for one reason, and
that reason is normally something you can fix in a minute.
A whole one, end to end — a query, then a document per row:
(pipeline "Example: A Document per Row"
(step "read-accounts"
:label "Query the accounts"
(action "DataverseQueryRows"
:connectionId "REPLACE_WITH_CONNECTION_ID"
:entitySet "accounts"
:select "name,emailaddress1"))
(for-each "each-account"
:label "One letter per account"
:in (output-of "read-accounts" "rows")
:max-parallel 4
:limit 500
(step "letter"
:label "Fill the letter template"
(action "ComposeFill"
:templateId "REPLACE_WITH_TEMPLATE_ID"
:data $item
:outputFormat "pdf"
:filename "Letter"))))
That loop names no :as, so the current item is $item. Four at a
time, five hundred at most.
Run in parallel — lanes that rejoin
A fan-out splits the spine into lanes that run at the same time. A lane can hold several steps, and they run in order within the lane:
(parallel "deliver"
(step "email" …) ; a one-step lane
(branch (step "stamp" …) ; a lane of two, run in order
(step "file" …)))
Lanes cannot see each other — they run concurrently, so each one reads the world as it was when the split happened. The step below the join is the merge: it runs once, after every lane has finished, and it can read each lane's result. A lane's result is its last step's. If one lane fails, its siblings and everything after the join still run, and the fan-out records which lane failed.
A whole one — a two-step lane, a one-step lane, and the merge below them:
(pipeline "Example: Two Lanes and a Merge"
(step "render"
:label "Render the document"
(action "ConvertHtmlToPdf"
:html "<h1>Quarterly Report</h1>"
:returnBase64 #t))
(parallel "deliver"
:label "Two lanes at once"
(branch
(step "stamp"
:label "Add a watermark"
(action "WatermarkPdf"
:pdf (output-of "render" "pdf")
:watermark.text "CONFIDENTIAL"
:returnBase64 #t))
(step "fingerprint"
:label "Hash the stamped copy"
(action "GenerateHash"
:data (output-of "stamp" "pdf"))))
(step "describe"
:label "Read its metadata"
(action "GetPdfMetadata"
:pdf (output-of "render" "pdf"))))
(step "count"
:label "The merge: runs after both lanes"
(action "ArrayCount"
:array (output-of "deliver" "succeeded"))))
The first lane is written as (branch …) because it holds two steps; the
second is a bare (step …) because it holds one. Both are lanes and the canvas
draws them the same way — the difference only shows in the Source tab.
When a step inside a lane fails, that lane stops there and everything else carries on. The steps that ran before the failure really ran: their results are kept and they are billed. A later step that refers to one that never ran is told exactly that — rather than "unknown step", which would send you hunting for a typo that is not there.
If — take one lane or the other
A decision compares one value and runs the matching lane. The other lane is skipped entirely, and skipped steps are never billed.
(if "route"
:when $input.status :is equals "approved"
(branch (step "approve" …)) ; runs when it matches
(branch (step "reject" …))) ; runs when it does not — optional
The comparison is one of equals, not-equals,
contains, greater, less and
exists, and it is evaluated once, before either lane starts. A field the
payload does not carry is treated as missing rather than as an error — routing on
whether something was supplied is the point of it. Numbers and the text of numbers
match each other, because a web form posts strings.
| Operator | True when |
|---|---|
equals | Both sides read the same. 5 and "5" match. Case-sensitive. |
not-equals | The opposite of the above. |
contains | The left side is a list holding that value, or text holding that text. |
greater / less | Compared as numbers when both sides are numbers, and alphabetically otherwise — which is how "2026-01-02" comes out greater than "2026-01-01". |
exists | The value is set and is not empty. It takes no comparison value; the box for one disappears when you pick it. |
Because only one lane runs, the price shown before the click is a ceiling — “up to 5 tokens” — not a total.
A whole one, with both lanes written out:
(pipeline "Example: Approved or Not"
(if "route"
:label "Is it approved?"
:when $input.status
:is equals "approved"
(branch
(step "letter"
:label "Render the approval letter"
(action "ConvertHtmlToPdf"
:html $input.letterHtml
:returnBase64 #t)))
(branch
(step "tell-them"
:label "Email the decision"
(action "SendEmail"
:to [$input.email]
:subject "We could not approve this"
:body "<p>Thanks for the submission.</p>")))))
The inspector writes :when and :is for you and reads the whole
thing back as a sentence on the card, so the picture and the source cannot drift apart.
The other things a step can refer to
| Write this | It means |
|---|---|
(output-of "step-id" "field") |
A field of an earlier step's result. Steps inside a lane may refer to steps above the split and to earlier steps in their own lane, never to a sibling lane's. |
$input |
The whole JSON body the webhook was called with — the form submission, as one object. |
$input.customerName |
One field of that body. The inspector lists the fields for you when a web form is attached to the workflow. |
$item / $row |
The current item, inside a for-each and nowhere else. The
name is whatever the loop's :as says; the default is
item. See
Repeat, split and decide. |
$row.email |
One field of the current item. |
$index |
Which item this is, counting from zero. Inside a loop only. |
(output-of "each-row" "items") |
What a loop produced: one entry per item, in row order, with an empty
slot where an item failed. "failed" is the list of the ones
that did. |
(default (output-of "lookup" "email") "ops@example.com") |
Use the first value; fall back to the second when it is missing or empty. |
(file "contract.pdf") |
A file you uploaded in the builder's Files panel. For
trying things out — a published workflow gets its documents from
$input or from an earlier step. |
What the caller gets back, and what happens when a step fails
The terminator at the bottom of the spine names the step whose output is returned to the caller. By default that is the last step; set it to any step from the inspector when the thing you want back is not produced last — a workflow that renders a PDF and then files it away should return the PDF, not the filing receipt.
Each step also has an if this fails posture. The default stops the run and returns the error. You can instead let the pipeline carry on without that step, or nominate another step to run in its place — a card marked that way sits on the spine with a runs on failure of … chip so it is obvious what it is for.
Try it without an account
The free playground is the same builder, running against a safe subset of the catalog, with no sign-up and nothing to install. Save, load and publish need an account; everything else works. Open Samples for a ready-made workflow — including one that fans out to two destinations and one that makes a document for every row in a list — or ask the assistant to build one from a sentence.