Documentation
Flows and automation
A flow is a drawing that runs. You put nodes on a canvas, draw wires between them, and the platform walks that drawing every time the moment you picked comes around. This page says what a flow can do, what each node means, and exactly where its limits are.
What a flow is
A flow is a directed graph: nodes with wires between them, walked from one starting point. It is deliberately not a script that loops. Four rules fix the shape, and the editor refuses to deploy a drawing that breaks one of them, naming the node that is wrong.
- Exactly one trigger. The trigger node is the only way in, and it decides which variables exist in the rest of the drawing.
- No cycles. A wire that leads back to a node it came from is refused, because a flow has no way to wait: a loop would simply never end.
- Every node has to be reachable from the trigger. A node hanging loose is a mistake you cannot see on a screenshot, so it is a mistake at deploy time.
- Branches are walked one after another, never side by side. Two branches at the same time would write into state at the same time, and the outcome would depend on timing.
The canvas
The editor is a canvas and there is no second one. A new flow opens on it straight away, and a flow that was a straight list of steps opens as a straight chain of nodes.
- The palette holds the twelve node types. Click one to drop it on the canvas, or drag it to the spot you want.
- Drag a node by its header to move it. Everything else on the node is a control, so it does not start a drag.
- A wire runs from an output dot to an input dot. Drag from the one to the other, or click the output and then the input. Both do the same thing, and the second one is the keyboard path.
- Let a wire go in empty space and nothing happens. To remove one, select it and press Delete, or use the cross that sits on it.
- Zoom between 50 and 200 percent, use Fit to see the whole drawing, and drag the background to pan. There is no minimap.
The nodes
Twelve types, and the list is short on purpose. Every type is a panel, nine translations, a branch in the runner and a row in the truth table, so a type that carries its weight is worth more than a type that almost does.
| Node | Outputs | What it does, and where it stops |
|---|---|---|
trigger | out | The one entry. Its event decides which variables exist. A manual trigger also carries the JSON you inject: at most 2048 characters, 40 keys, and 512 characters per value. |
script | ok · error | Renders a script template against a target. It leaves through ok, or through error when rendering fails. Tick continue on error and a failure keeps walking anyway. |
switch | rule-0 … · otherwise | Looks at one value and takes the first rule that fits; everything else leaves through otherwise. At most twelve rules, and a compared value of at most 512 characters. |
if | true · false | One condition, two outputs. Same operators and the same evaluation as a switch rule, drawn as the yes or no it really is. |
set | out | Writes into state, where a later node or script reads it back. At most twelve assignments per node and 4096 characters per value. Names are yours; http is reserved. |
http | ok · error | Calls a URL and puts the answer in state.http. GET or POST, at most eight headers, a timeout between 0.5 and 10 seconds, and a response cap between 256 bytes and 256 KiB, 64 KiB by default. At most three redirects, each one screened again. |
notify | out | Writes a notification in the panel of this account, at level info or warning. Title at most 120 characters, body at most 1000. Tick Also send by email and it goes out as mail too, to everyone with access to Scripts who has not switched off Flow notifications under Email notifications. A test run never sends mail. |
action | ok · error | Starts, stops, shuts down, suspends or resumes the server this run is about, and waits up to 300 seconds for the command to finish. Zero means do not wait: accepted is not the same as running. |
delay | out | Pauses the run. Up to 30 seconds inside the request that started it; longer and the run is parked and picked up later, to a maximum of 30 days. |
approval | approved · rejected | Stops the run until a person approves or rejects, with a deadline and a chosen outcome if nobody answers. |
foreach | loop · done | Walks the loop output once per item of a list, read as JSON, one per line, or separated by commas. |
repeat | loop · done | Walks the loop output as long as a condition holds, and reads that condition again before every round. |
break | — | Leaves the loop it stands in; the run continues at the done output of that loop. |
subflow | ok · error | Runs another flow of this account and waits for its outcome. The child gets its input as input.*. |
log | out | Writes one line into the run, the way the debug node does in Node-RED. The field holds 1000 characters; what lands in the run is the first 200. |
note | — | Text on the canvas. It has no ports and is never walked, so it never shows up in a run. |
end | — | Ends this branch and says how the run turned out: succeeded, failed, or skipped for a server this flow was not meant for. A message of at most 280 characters goes with it. |
Triggers
Five moments, and the one you choose is not only when the flow runs. It is also which variables exist, which the picker offers you, and which the platform refuses to save. A path that cannot exist at that moment is an error while you save it, not an empty string in the middle of a run.
| Trigger | When it runs | What is there, and what is not |
|---|---|---|
server.provisioned | Once, right after an order has produced the machine. | Everything. This is the only moment where every group exists. |
server.reinstalled | After a reinstall of an existing machine has succeeded. | Same machine, new image. The address is unchanged and the creation date is still the original one. Today it fires for containers only; a reinstall of a virtual machine is finished by a component that cannot render. |
server.deleted | When a deletion has been accepted, while the row can still be read. | Everything except the image. There is nothing left to read an image from, and the platform will not invent one, so os is simply absent. |
manual | Never on its own. You run it from the editor with Inject. | An existing machine that you pick, plus your own JSON as input. There is no image here either: what is installed on a machine is not something the platform can look up afterwards. |
webhook | When a stranger posts to a URL of its own. | No machine at all, so no plan, no address, no connection and no agent. What is left is the account, the flow, the posted body as input, and the request itself as webhook. |
Variables
Every value a flow can read comes from one of these groups. The picker in the editor shows the ones that exist at your trigger and nothing else. Reading an unknown path is an error and never a blank: a wrong assumption should be loud.
| Group | Exists with | What it holds |
|---|---|---|
org.* | Every trigger | The account: id, name and slug. |
user.* | The four server triggers | The person the run acts for: id, name and email address. |
flow.* | Every trigger | This flow and this run: name, run id, step number and count, trigger, and when it started. |
server.* | The four server triggers | The machine: name, hostname, type, status, external id, node, cores, memory, disk and creation date. |
server.compute.* | The four server triggers | What kind of guest it is and what that kind can really do: kind, template, unprivileged, storage, mountpoints, capabilities and unsupported. All seven are optional, and missing means not established rather than no. |
plan.* | The four server triggers | The plan behind the order: name, cores, memory, disk and bridge. |
os.* | Provisioned and reinstalled | The image: name, slug and category. There is no family or version, because the catalogue does not have those columns and guessing them picks the wrong package manager. |
ip.* | The four server triggers | The address that was handed out, and whether it is version 4 or 6. |
network.* | The four server triggers | Everything around that address: cidr, gateway, netmask, network, broadcast and the resolvers. |
prefix.* | The four server triggers | The address pool it came from, with its range and its parent. |
integration.* | The four server triggers | The connection that carries the machine: name, type, node, storage, bridge and the vmid range. |
agent.* | The four server triggers | The agent that did the work: name, status, version, hostname, operating system, kernel and cores. |
input.* | Manual and webhook | What you injected, or what the webhook posted. The keys are yours, and the picker follows them. |
webhook.* | Webhook only | The request itself: which endpoint, when it arrived, and the address it came from. |
state.* | After a set node | Whatever a set node in this flow wrote. The names are yours, and they only exist below the node that writes them. |
state.http.<node>.* | After an http node | Per http node: status, ok, body, json, error and url. All six always exist, on the good path and on the bad one. |
device.* | Nothing yet | Designed for network equipment. There is no provider that can fill it, so no flow can use it yet. |
Expressions
A set node writes into state. Its value is literal text, unless you tick the expression box; then it is read as a template with the variables of this run filled in.
- The tick is deliberate. A value that happens to hold two braces, a piece of Ansible or a Helm fragment, keeps meaning what it said. Nothing changes meaning because of a change nobody asked for.
- The language is output only. Two braces and a path, and nothing else: no assign, no capture, no loop, no include. What stands on the canvas is what comes out, with the values filled in.
- There are 46 filters: text, numbers, dates, the few list filters that produce text, and the platform's own quote, ini and ipoctet. The picker in the editor lists them. Two families are missing on purpose, the ones that roll dice and the ones that hand back an object where you wanted a word.
- Secrets are not available here, and neither are snippets. What a set node writes ends up in the record of the run, and a secret has no business being there.
Approvals and loops
A flow can stop and wait for a person, and it can walk the same nodes more than once. Both were impossible until a run could be parked and woken up again; that queue exists now.
- An approval node stops the run, writes a request and notifies the account. Answering it needs a separate right, so the person who wrote the flow is not automatically the person who signs it off.
- Every request carries a deadline and what happens without an answer: continue as approved, continue as rejected, or end the run as not applicable. There is no fourth choice, because a run that waits forever is a run nobody hears about again.
- For each walks its loop output once per item of a list. Inside the loop, state.loop holds the current item, its position and the total.
- Nothing from outside a loop may hang on the nodes inside it. Those nodes run again on every round, and a notification wired to both would write one message per round.
- An approval step can send email as well. That mail goes only to the members who may decide on approvals, not to everyone who can see flows, and the panel notification is written either way.
Waiting, and flows that call flows
A run that stands still is not a run that failed. Three things can park one, and all three use the same queue: an approval, a long pause, and a subflow whose child is itself waiting.
- A delay of half a minute or less simply waits inside the request that started it, which is what you want when you are giving an agent a moment. Anything longer parks the run, so the order that started it finishes right away and the rest of the flow follows later.
- A subflow runs another flow of this account as one step. Its outcome decides the output: a child that succeeded or found nothing to do continues over OK, a child that failed over Error. The status, the run and the flow name stand in state.subflow.
- A flow may never reach itself, directly or through another flow. That is checked when you deploy, and the message names the chain it found.
- Flows may call each other at most three levels deep. Because flows are edited on their own, that limit is checked twice: once when you deploy, and once while the run is actually walking.
Webhooks
A webhook endpoint gives an outside system a URL of its own. It posts JSON, the flow behind the URL runs, and the answer is a bare status code. Endpoints live under Scripts, on the Webhooks tab, and are separate objects: the same endpoint can be pointed at another flow without the sender noticing.
- Create the endpoint and give it a name you will recognise later. The URL is generated for you and cannot be chosen: 32 random characters, which is 160 bits, so guessing one is not a strategy.
- Pick how the sender proves who it is, and copy the secret. It is shown once and never again; lose it and you regenerate, which also gives you a new URL.
- Point the endpoint at a flow whose trigger is a webhook. The endpoint may exist before the flow does, and until then a request is accepted and answered with a 202 that starts nothing. That is the order in which people actually build this, so it is not treated as a mistake.
| Scheme | What it does |
|---|---|
none | No proof at all: whoever knows the URL can start the flow. Defensible with an address allowlist next to it, and hard to defend without one. |
hmac_sha256 | The sender signs the raw body together with a timestamp. This is the one to pick: it binds the request to its own body, so a captured request cannot be edited and sent on. |
bearer | The sender puts the shared token in the Authorization header. Simple, and it says nothing about the body it came with. |
The signature is the word sha256, an equals sign, and the HMAC-SHA256 of the exact bytes of the timestamp, a dot, and the body, in lower case hexadecimal. It goes in X-Provibr-Signature, and the timestamp itself goes in X-Provibr-Timestamp as whole seconds since the epoch.
A request whose timestamp is more than 300 seconds old, or in the future, is refused with a 401. The window is a real rule and not decoration, because the timestamp is inside the material that was signed: a captured request cannot be replayed tomorrow with a fresh timestamp pasted over it.
Signing a request is one line of openssl. This example posts a small object; the shell text stays English on purpose, so it matches what your terminal shows you.
# Sign the raw body together with the timestamp, then post it.
SECRET="$PROVIBR_WEBHOOK_SECRET"
BODY='{"reason":"maintenance","attempt":1}'
TS=$(date +%s)
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -r | cut -d' ' -f1)
curl -sS -X POST 'https://app.provibr.com/api/hooks/flows/<your-slug>' \
-H 'Content-Type: application/json' \
-H "X-Provibr-Timestamp: $TS" \
-H "X-Provibr-Signature: sha256=$SIG" \
--data "$BODY"| Limit | Details |
|---|---|
body | 64 KiB by default, adjustable between 1 KiB and 256 KiB. A bigger request is cut off while it is still arriving, not read in full and then rejected. |
rate | 60 requests per minute by default, adjustable between 1 and 600. It counts per endpoint and not per sender, because a real source has one address and an attacker has a thousand. |
payload | A JSON object one level deep. Each key becomes input followed by that key; a nested object is refused rather than flattened. |
allowlist | At most 20 addresses or ranges, in v4 or v6. Empty means no restriction at all. |
endpoints | At most 50 endpoints per account. |
| Code | Meaning |
|---|---|
202 | Accepted. Either a run started, or there was nothing to start: no flow linked yet, the flow is switched off, or the run itself fell over. In all four cases the sender did nothing wrong, and a receipt says which of the four it was. |
401 | The signature or the token is missing or wrong, or the signature is right and its timestamp falls outside the window. |
403 | The endpoint is switched off, or the source address is not on the allowlist. |
404 | There is no endpoint with this slug. |
410 | The endpoint was revoked. The slug stays taken forever, and this answer tells an honest source that it needs a new URL, which a 404 would not. |
413 | The body is larger than this endpoint accepts. |
422 | The body is not a JSON object of one level. |
429 | Over the rate limit of this endpoint. |
Every request leaves a receipt, accepted or not: the outcome, the status that went back, the source address in full, and how many bytes were really read. The body is never kept. Receipts are removed after 90 days, sooner than the runs themselves, because a source address is personal data belonging to somebody who is not our customer.
Revisions and runs
Two questions that look alike have two different answers here. Who changed what, and when, is a revision. What did this particular run actually walk, is a copy kept on the run itself. A snapshot cannot answer the first question and a revision cannot answer the second, so both exist.
- Every deploy writes a revision, including a deploy that changed nothing. The number counts up per flow and is never reused.
- The difference between two revisions is a readable list and not raw JSON: a node added, removed or changed with the fields named, a wire added or removed. Where a node sits on the canvas is not a change.
- Restore does not rewind. It writes the old drawing back as a new revision on top of the history, with a note saying which one it came from.
- Every run keeps the drawing it walked. Editing a flow afterwards never rewrites what an older run did.
- A run holds a row per node it visited, with the outcome and the output it left through, and a row per node it did not visit with the reason. Not visited is a fact worth writing down, not an absence.
- The run list filters on trigger, status and revision, and exports to CSV. The export stops at 5000 rows and says so inside the file rather than quietly ending.
Script templates keep a history of their own, next to this one: every saved version of the body, and a line by line difference between any two of them.