Workflows
Automate a repeatable process with steps that call out, wait, re-check your data, and ask a person to decide.
A Workflow runs a process you repeat: a new lead comes in, Busabase scores it, tells your CRM, waits a day, checks the lead again, and asks your sales lead to approve the handover. You draw the steps once, and each run goes through them on the server. Nobody has to keep a tab open.

Build a workflow
Create it
Press + in the sidebar and choose Workflow. If it isn't listed, open More types.
Add steps and connect them
Add steps with + in the workflow's toolbar, then drag from one step to the next. Select a step to set it up under Configure.
Save
Press Save. Runs always use the last saved version, so starting a run is blocked while you have unsaved changes.
What each step does
| Step | What it does | Connections out |
|---|---|---|
| Trigger | Where a run starts. It reads the record the run is about, if there is one. | One |
| Function | Runs JavaScript written in the step, see Write code in a step. It can also run a saved webhook rule whose action is Run function. Its return value stays on the step. | success |
| Webhook | Sends one HTTP request with the run's data as a JSON body (GET sends no body), with any Headers you add. The response becomes the step's output. | success |
| Condition | Checks an expression and takes one of two paths. | true, false |
| Wait | Pauses the run for minutes, hours, or days. The pause survives restarts and deployments. | elapsed |
| Action | Reads the record again, or updates, creates, or deletes one. See Change records. | success |
| Approval | Pauses until someone approves or rejects. | approved, rejected |
| End | Finishes the run with an outcome. | None |
A connection's Branch outcome decides when it is followed. A run never visits the same step twice, and it stops with an error after 64 steps.
Use earlier results in a condition
A condition can read the step right before it as input, and any earlier step through its reference. Select a step and Use this step in formulas as shows what to write, with a button to copy it:
input.status == 200 && steps.score.score >= 80&& and || stop as soon as the answer is known, and a value that is missing counts as null, so input.body && input.body.ok is safe. Use brackets for names that are not plain words: fields["follow-up"], items[0].
Does a Wait see the latest data?
Only if you ask it to. Every finished step keeps a snapshot of what it saw, so steps.trigger still shows the record as it was when the run began. To work with current values, put an Action step set to Read the record again after the Wait. It reloads the record, and the next step sees it as input.record.
Change records
An Action step can change your data. Choose what it does from the list:
| Action | What it does | Settings |
|---|---|---|
| Update a record | Sets fields on a record. | Record (optional), Fields to set |
| Create a record | Adds a record to a Base. Later steps get its id as createdRecordId on this step. | Base, Fields to set |
| Delete a record | Moves a record to its Base's trash, where it can be restored. | Record (optional) |
| Find records | Lists a Base's records, or the ones a View shows. See Act on many records. | Base, View (optional), At most, Run the next steps once for each record |

Record is the run's own record when left empty. It can also be a record ID, or a formula such as =steps.add.createdRecordId to change the record an earlier step created.
Fields to set is a JSON object:
{ "status": "qualified", "score": "=steps.score.score" }- Use a field's name or slug as the key. Plain values are stored as written.
- A text value that starts with
=is a formula over earlier steps. Start with==to store text that itself begins with=. - A formula that finds nothing fails the step, so a typo never blanks a field or changes the wrong record.
What else to expect:
- The step acts as the person the run acts as, with their access to that Base. If they can edit it, the change applies right away. If they can only propose changes, it goes to the Inbox for review, nothing changes yet, and the step fails and names the change request. With On error set to Continue, the run goes on.
- A change a step applies starts no workflow, including workflows listening to another Base. So a workflow that updates the record it listens to cannot loop, and workflows cannot set each other off. Changes a person makes, or approves later, start workflows as usual.
- If a run deletes its own record, later steps carry on without one, except Read the record again, which fails.
Act on many records
A scheduled run is not about any record. To work through records, add an Action step set to Find records:
- Pick the Base, and a View to take only the records it shows, in its order, such as an "Overdue" view. All records takes every record, newest first.
- Set At most: up to 200 records. Empty means 50.
- Choose what the steps after it get:
- The list, once. Later steps read
steps.<id>.records,count, andtotal. A Function step can loop over them, for example to post one summary. When more records match than At most, the list stops there andtruncatedis true. - Each record, in its own run. Turn on Run the next steps once for each record. The run ends at this step, and every record found gets a run that carries on from the next step, about that record: Update a record with an empty Record changes it, a Function step sees it as
input.record, and formulas read it assteps.<id>.record.fields.<field>.
- The list, once. Later steps read
With each record in its own run:
- If more records match than At most, no run starts and the step fails with the count, so a View that grows by mistake cannot start hundreds of runs. Narrow the View, or raise the limit.
- The runs show as Per record under Runs. They take turns under Concurrency, and the scheduler starts them within a minute or so. Cancel N queued runs stops the ones that have not started.
- A run started this way does not repeat again: a second Find records set to run once for each record fails. While 200 runs of the workflow are still waiting, it starts no more and fails.
Write code in a step
A Function step runs the JavaScript in its Code box on the server. Use it for anything the other steps don't do: call an AI agent's API, post to a chat tool, reshape data, or reply to whoever sent a request. Ask an agent to write it for you.
inputhas the run's data:input.payload,input.steps.<id>for earlier steps, andinput.recordwhen the run has one.await fetch(url, options)calls public URLs and answers like the standard fetch:res.ok,res.status,await res.json(),await res.text(),res.headers.get(name). Private and internal addresses are refused.- What you
returnis the step's output. Later steps read it assteps.<id>.<field>, and conditions can branch on it. - Limits: the workflow's Timeout, 10
fetchcalls per step, and answers are cut at 32,000 characters. There is norequire, file access, or environment. - Anyone who can open the workflow can read the code, so use keys made for this purpose that you can revoke.
Run heavier code in a sandbox
Code in a step runs inside the app: fast, but JavaScript only, with no packages and the workflow's Timeout. When a step needs more, such as an npm or pip package, Node's crypto, Python, or a few minutes, set Run on to A sandbox machine:
- Choose the Language: JavaScript (Node.js) or Python.
- List the Packages to install first, separated by spaces. Versions are allowed (
lodash@4,requests==2.32.3). - Write the code as before:
inputhas the run's data and what youreturnis the step's output. In JavaScript load packages withrequire("name"); in Python import them as usual.
// JavaScript in a sandbox: Node's crypto signs the request, no package needed
const crypto = require("node:crypto");
const body = JSON.stringify({ text: input.payload.request?.body?.text ?? "" });
const signature = crypto.createHmac("sha256", "<shared secret>").update(body).digest("hex");
const res = await fetch("https://partner.example.com/hook", {
method: "POST",
headers: { "content-type": "application/json", "x-signature": signature },
body,
});
return { status: res.status };# Python in a sandbox, with Packages set to: requests
import requests
res = requests.get("https://api.github.com/repos/busabase/busabase", timeout=10)
return {"stars": res.json()["stargazers_count"]}What to expect:
- When the run reaches the step, a fresh machine starts, installs the packages (up to 5 minutes), and runs the code (up to 10 minutes). Meanwhile the run shows Waiting, with "Running code in a sandbox", and it carries on when the code finishes. The machine is removed afterwards, and also when someone cancels the run.
- A run started by When a request comes in answers the caller with
202once it reaches a sandbox step, so reply from a step that runs on this server. - On Busabase Cloud a sandbox comes with the same plans as an AirApp's remote engine; on a plan without it, the option shows locked with the way to upgrade. On your own server, set
SANDOCK_API_KEY(andSANDOCK_BASE_URLfor your own Sandock) to turn it on. - Logs are cut at 4,000 characters. Anyone who can open the workflow can read the code, so use keys made for this purpose.
Start a run
By hand. Open the Runs tab, or press the run button in the toolbar. You can pass a Record ID for the trigger to read, and Payload JSON for any extra data. Then press Start run.
When a record changes. Under Configure:
- Select the Trigger and choose When a record is created or When a record is updated.
- Pick a Base to limit it to, or leave it on all Bases.
- Set Execution mode to Event, then save.
Each matching change queues one run, and the scheduler starts it. Event runs act as the person who last saved the workflow in Event mode. If that person leaves the space, those runs fail and say why.
Only when a field changes. With a Base picked, choose a field under Only when this field changes. A run then starts only when that field gets a different value, and edits to other fields start nothing. Pick a value under Changes to to start only when the field becomes that value, for example when Status becomes Ready. Editing a record that is already Ready starts nothing, and a record created as Ready counts.
On a schedule. Select the Trigger, choose On a schedule, and pick how often:
| Repeat | When it runs |
|---|---|
| Every few minutes | Every N minutes, at least 15, on the clock: every 15 minutes is :00, :15, :30, and :45. |
| Every few hours | Every N hours, on the hour (UTC). |
| Every day | At the time you pick. |
| Every week | On the day and at the time you pick. |
Day and week times are in your time zone when you save, which is shown with the next run time. Set Execution mode to Event and save. A scheduled run is not about any record, so Read the record again has nothing to read. If the scheduler was down when a run was due, it starts one run when it is back, not one for every time it missed.
When a request comes in. Select the Trigger and choose When a request comes in. The trigger gets its own Incoming URL; copy it into the service that should call you, set Execution mode to Event, and save. Every POST (JSON or text) or GET to that URL starts a run:
- The request is in
steps.trigger.payload.request:method,query,headers, andbody(parsed when it is JSON).AuthorizationandCookieheaders are not kept. - The run starts at once. If a Function step returns
{ response: … }before the run pauses, the caller gets that answer: the value itself, or{ status, contentType, body }. Text goes back as text, anything else as JSON. Otherwise the caller gets202with the run's ID. - Anyone with the URL can start a run. Change URL makes a new one and the old one stops working once you save.
- A wrong URL, or a workflow not in Event mode, answers
404. Bodies over 256 KB answer413, and when 200 runs are already waiting the URL answers429.
Other event names are labels only. They do not start runs by themselves.
Examples
Every day at 10:00, ask an agent and post the answer to a WeCom group
Trigger set to On a schedule, Every day at 10:00 → a Function step askAgent → a Function step that posts to the group → End.
// "Ask the agent": call your agent's API (any HTTP API works the same way)
const res = await fetch("https://your-agent.example.com/v1/chat", {
method: "POST",
headers: { "content-type": "application/json", authorization: "Bearer <agent key>" },
body: JSON.stringify({ prompt: "Summarise today's follow-ups in three lines" }),
});
const data = await res.json();
return { reply: data.reply };// "Post to WeCom": a group robot's webhook
const res = await fetch("https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=<robot key>", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ msgtype: "text", text: { content: input.steps.askAgent.reply } }),
});
return { wecom: await res.json() };A support bot that routes messages
Trigger set to When a request comes in → Condition steps.trigger.payload.request.body.text == "1" → reply step; on false, another Condition for "2" (and inside it, one more for the kind of problem), then "3" hands the question to an agent step whose answer the next step returns, and anything else gets the menu.
// A reply step: whatever you return as `response` goes back to the caller
return { response: { msgtype: "text", text: { content: "Opening hours: Mon–Fri 9:00–18:00" } } };Every morning, remind the owner of each overdue task
Trigger set to On a schedule, Every day at 09:00 → Find records in Tasks with the View "Overdue", and Run the next steps once for each record on → a Function step that sends the reminder → Update a record with { "reminded": true } → End.
// "Remind the owner": this run is about one overdue task
const task = input.record.fields;
const res = await fetch("https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=<robot key>", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ msgtype: "text", text: { content: `${task.owner}: "${task.title}" is overdue` } }),
});
return { sent: res.ok };Follow a run
The Runs tab lists the newest runs of this workflow, and Load older runs goes further back. Select a run to see each step in the order it ran, with what it saw and returned. The canvas marks the same steps as done, paused, or failed.
| Status | Meaning |
|---|---|
| Queued | Waiting to start or continue. It moves on its own, usually within a minute. |
| Running | Executing a step right now. |
| Waiting | Paused on a Wait. Continue now skips the rest of the time. |
| Needs approval | Paused on an Approval. |
| Completed | Reached an End step. |
| Failed | Stopped with an error. The reason is shown on the run. |
| Cancelled | Someone stopped it. The run shows who. |
Retry a failed run from the step that failed
When a run fails (a partner's server was down, a key had expired), select it and press Retry from this step. Busabase starts a new run that keeps every step before the failed one exactly as it was, and runs the failed step again with its current settings. So fix the step or the other side first, then retry.
- Steps before the failed one are not run again, so a record they created or a message they sent is not created or sent twice. The failed step itself runs again: if it had already sent a request before it failed, that request is sent again.
- The new run shows Retry in the list and links back to the failed run, which links forward to it. A run is retried once; if the new run fails too, retry that one in turn.
- The button is offered only when it can work: the run failed on a step that is still in the workflow. If the step was deleted since, or the run failed after its step finished, there is nothing to retry from.
- The new run acts as you, not as whoever started the failed run, and needs write on the workflow. Agents can do the same with
busabase-cli workflows retry --run-id <id>or theworkflows_retryMCP tool.
Finished runs are kept for 90 days, then deleted. Runs that have not finished are never deleted.
Approvals

The Approver field decides who can approve or reject:
- Member emails, separated by commas: only those members and space admins can decide. They need read access to the workflow, but not edit access. Pick members from Add a member… under the field instead of typing their emails.
- No email (empty, or a name like "Sales lead"): anyone who can edit the workflow can decide. A name is just a label.
- Agents can't decide unless you turn on Let agents decide. See Let agents use workflows.
When a named approver who cannot edit the workflow decides, the run goes to Queued and the scheduler continues it with the access of the person who started it, so an approver's narrower access never changes what the later steps can do.
Workflow approvals live in the Runs tab and in notifications. They are separate from the Inbox list of Change Requests. The mobile app also shows a banner for them at the top of its Inbox.
Cancel a run
Select a run that is Queued, Waiting, or Needs approval, then press Cancel run. A cancelled run cannot be continued, and steps that already ran are not undone.
A run that is executing a step cannot be cancelled until that step finishes, because a request already sent cannot be taken back.
If a bulk import queued more runs than you wanted, Cancel N queued runs at the top of the list stops all the queued ones at once. Runs that are executing a step or paused keep going.
Notifications
| When | Who hears about it |
|---|---|
| A run stops on an Approval that names people | Those people |
| A run stops on an Approval that names nobody | The person who started the run, and people subscribed to the workflow |
| A run fails | The person who started the run, and the workflow's creator |
You get an in-app notification and an email, and the link opens that run. Whoever is pressing the button at that moment is left out, because they can already see it. Bursts are rolled up: approvals into one message per workflow every 10 minutes, failures into one per hour. The open-source single-user app has no notifications, so check the Runs tab there.
Permissions
| To do this | You need |
|---|---|
| See runs and their step data | Read access to the workflow |
| Start, continue early, or cancel | Edit access to the workflow |
| Have a step update, create, or delete a record | What the person the run acts as may do in that Base: edit it directly, or propose a change |
| Approve or reject | Depends on the step, see Approvals |
Function and Webhook steps send data outside Busabase, and Action steps can change your data, which is why starting a run needs edit access. Anyone who can read the workflow can see each step's data, so don't put secrets in a payload.
Settings
Select nothing on the canvas (or scroll down under Configure) to change these for the whole workflow.
| Setting | What it does |
|---|---|
| Execution mode | Manual starts runs only by hand. Event also lets a record Trigger or a schedule start them. |
| On error | Stop workflow fails the run when a Function or Webhook step fails. Continue records the error and follows the success path. |
| Concurrency | How many runs of this workflow can execute steps at the same time. Runs paused on a Wait or an Approval don't count. Extra runs queue and start when a slot frees up. With several servers, two starting at the same instant can briefly go one over. |
| Timeout (ms) | The longest a Function or Webhook step can take. A Webhook step never waits more than 30 seconds. |
Webhook steps send once and do not retry, because repeating a request is not always safe. Private and internal addresses are refused, including through redirects.
Let the receiver check a request came from this step
Switch on Sign requests on a Webhook step and Busabase shows a Signing secret: copy it into the service that receives the request. Every request then carries a header the receiver can check:
X-Busabase-Signature: t=1700000000,v1=10fb0e5eb2c2204d471e7d252958fb5af663638b8d24fc8dc03090a74bcc366ct is the time it was sent (Unix seconds) and v1 is the hex HMAC-SHA256, keyed with the secret, of the text <t>.<body>, where <body> is the request body exactly as sent (empty for a GET). The format is the same as Stripe's, so a verification snippet you already have for it works. To check a request, recompute the HMAC and compare it, and reject a t more than five minutes from now: that is how a replay is caught, because moving t forward changes the signature.
// Node.js: the receiving side. `rawBody` is the request body exactly as it arrived (a string).
import { createHmac, timingSafeEqual } from "node:crypto";
function verifyBusabase(secret, header, rawBody, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(",").map((part) => part.split("=", 2)));
const t = Number(parts.t);
if (!Number.isInteger(t) || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false; // too old: a replay
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
const given = Buffer.from(parts.v1, "hex");
return given.length === expected.length && timingSafeEqual(given, expected);
}
// verifyBusabase("whsec_...", request.headers["x-busabase-signature"], rawBody)# Python: the receiving side. `raw_body` is the request body exactly as it arrived (bytes).
import hashlib, hmac, time
def verify_busabase(secret: str, header: str, raw_body: bytes, tolerance_seconds: int = 300) -> bool:
parts = dict(part.split("=", 1) for part in header.split(",") if "=" in part)
try:
t = int(parts["t"])
except (KeyError, ValueError):
return False
if abs(time.time() - t) > tolerance_seconds: # too old: a replay
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))- Change secret makes a new one at once. The old one stops verifying the requests sent after you save. Switching Sign requests off removes the secret and the header.
- Anyone who can open the workflow can read the secret, like a header, so give each receiver its own step and secret, and change it if someone who could read it should no longer be trusted.
- A step cannot send an
X-Busabase-Signatureheader of its own: with signing off, no such header is sent.
A Webhook step's Headers go with every request it sends, for example Authorization: Bearer … for an API that needs a key. Anyone who can read the workflow can see them, so use a key made for this job that you can revoke. If the other site redirects the request to a different site, Authorization and Cookie are not sent on.
Let agents use workflows
Agents with an API key can use workflows through the REST API, MCP, and the CLI.
| Agents can | Needs |
|---|---|
| See runs and their step data | A key that can read the workflow |
| Start a run, or cancel one | A write-level key, and edit access to the workflow |
| Approve or reject | A step that allows it, see below |
Continuing a Wait early and cancelling everything queued stay in the app.
An approval is a person's decision by default. Select the step and turn on Let agents decide to allow an agent. Even then, the key must be write-level and its owner must meet the step's approver rule. The run records which key decided.
A run never outgrows the key that started it. It acts as the key's owner, limited by the key, and the run shows the key's name. Busabase checks the key again each time the run continues, so a run that is waiting stops with a clear error if you revoke the key or it expires, and it can't write if you lower the key's level.
Alerts about a run an agent started go to the key's owner too, because they aren't watching it happen.
Good to know
A run stays Queued for a long time. The workflow may be at its Concurrency, so the run is waiting for a slot. Otherwise check that the scheduler is running. It is built into self-hosted installs (configuration).
"The run was interrupted". The server stopped while a step was running. Busabase marks the run as failed instead of repeating the step, because a half-sent request must not silently run twice. Start it again.
"Could not continue as the person who started this run". That person has left the space or lost access to the workflow. Start a new run, or save the workflow in Event mode yourself so event runs act as you.
A URL answers 404. Check that Execution mode is Event, that you saved after choosing When a request comes in or Change URL, and that the whole URL was copied.
A Function step fails with a JSON error. An answer longer than 32,000 characters is cut and is no longer valid JSON. Ask the API for less, or read it with await res.text().
A schedule never starts a run. Check that Execution mode is Event and that you saved. The Trigger shows when the next run is due.
A field trigger doesn't start. The run starts only when the chosen field gets a different value. With Changes to set, a record that already has that value starts nothing until it changes away and back.
Approve and Reject don't appear. The step names other approvers, or you don't have edit access. The run tells you which rule applies.
The demo fails on Function and Webhook steps. The demo has no saved functions and sends no requests. Try those steps in your own space.
A formula fails on a step with a dash in its id. Write steps["step-a1b2c3d4"].status, not steps.step-a1b2c3d4.status. The step's Use this step in formulas as shows the right form.
See also: Node Types · Webhooks & Events · Change Requests