Workflows
Last updated on May 21, 2026
A workflow ties a trigger event to a chain of actions, with optional control flow in between, and you author it from a visual editor. Workflows move through a status lifecycle of Draft → Active → Paused → Archived, and only Active workflows respond to events.
Anatomy
Every workflow you build is composed of three parts that flow into one another in order.
Triggers
A workflow has exactly one trigger, and each trigger exposes event variables that downstream nodes can reference.
| Trigger | Fires when |
|---|---|
run_created | A test run is created |
unit_created | A unit is created |
batch_created | A batch is created |
part_created | A part is created |
revision_created | A part revision is created |
schedule | Time-based (daily/weekly/monthly), with timezone and day selection |
manual | On demand from the editor |
Rate limits: 100 automatic triggers per minute. 10 manual triggers per minute per organization.
Control flow
Control-flow nodes sit between the trigger and the actions, and they decide which downstream paths execute.
| Node | Behavior |
|---|---|
| Filter | Single condition gate. Stops downstream when false. |
| If/Else | Two-path branch on a true/false condition. |
| Switch | Multi-path branch with up to 20 cases plus a default path. |
Conditions support equals, contains, starts with, greater than, regex match, is null, and more, and you can combine them with AND/OR.
Actions
Actions are the nodes that do work, and they are grouped by the system they talk to.
Messaging
Messaging actions push a message into a channel or inbox you already use.
Send email
Up to 50 recipients with variable interpolation.
Send Slack message
Post via incoming webhook URL.
Send Discord message
Post via webhook URL.
HTTP
The HTTP action calls any external API, so you can wire TofuPilot events into systems without a first-party integration.
HTTP request
Call external APIs with configurable headers, body, timeout, retries.
Odoo
Odoo actions read and write records in your Odoo instance.
Create record
Create a record on any Odoo model.
Update record
Update an existing record by id.
Post message
Post on the chatter of an Odoo record.
InvenTree
InvenTree actions sync test data and stock movements with your InvenTree instance.
Upload test result
Push a phase or run outcome onto a stock item's test report.
Update stock status
Change the status of a stock item.
Transfer stock
Move a stock item between locations.
Complete build output
Mark a build output as complete.
Print label
Trigger a label print.
Linear
Linear actions open and comment on tickets in your Linear organization.
Create issue
Optional assignee, labels, estimate.
Add comment
Comment on an existing issue.
Utilities
Utility actions cover pacing, math, and a little bit of operator-facing polish.
Delay
Pause execution for 1 second to 1 hour.
Math operation
Add, subtract, multiply, divide, modulo.
Celebrate
Trigger confetti for manual triggers.
HTTP request: 1-60 second timeout (default 10), 1 MB max body, up to 3 retries. Email: 50 recipients max.
Variables
Variables let downstream nodes read values produced upstream, and you reference them with {{nodeId.fieldKey}}.
- Trigger variables: fields from the event.
- Control flow variables: filter result and the branch taken in if/else or switch.
- Action output variables: HTTP response status, created record id, and so on.
Missing variables render as [not set].
Editor
The editor is a node-and-edge canvas with shortcuts that mirror what you would expect from a graph editor.
- Click + below any node or use the bottom toolbar to add nodes.
- Click a node to configure it in the right panel.
- Connect nodes by dragging between handles.
- Add notes to annotate.
- Use Organize actions to auto-layout, and
Cmd+Zto undo.
Limits: 100 nodes, 200 edges, 50 notes per workflow.
Execution history
The Executions tab lists past runs with their status, runtime, trigger source, and per-node results, so you can audit what each fire actually did.
Versioning
Edits auto-save and version automatically. While the workflow is in Draft, you can click Save as new version to create a named snapshot, and you can browse, preview, and restore prior versions from the version badge in the header.
Webhook signing
TofuPilot handles webhooks in two directions: inbound from connected providers such as GitHub, GitLab, and Linear, and outbound through the http_request action.
Outbound webhooks
The http_request action sends to a URL you configure, with {{variable}} placeholders interpolated in the URL, headers, and body. The Content-Type: application/json header is set by default.
Outbound HTTP requests are not signed. No HMAC, no timestamp, no nonce. Add your own check.
You have three options for authenticating the call on the receiving side:
- Long random token in the URL path such as
https://example.com/hooks/tofupilot/<token>, with mismatches rejected. - Custom
Authorization: Bearer <token>header on the action, validated server-side. - Shared secret in the JSON body, checked before processing.
Inbound webhooks
TofuPilot verifies provider signatures using the secret stored on the installation, so you do not need a verifier on your side. You configure the secret on the provider's dashboard when installing the integration.
| Provider | Endpoint | Header | Algorithm |
|---|---|---|---|
| GitHub | /api/webhooks/github | x-hub-signature-256 | HMAC-SHA256 of raw body, hex, prefixed sha256= |
| GitLab | /api/webhooks/gitlab | x-gitlab-token | Shared secret matched against sha256(token) |
| Linear | /api/webhooks/linear | linear-signature | HMAC-SHA256 of <timestamp>.<body>, formatted t=<timestamp>,s=<signature> |
Each handler reads provider delivery headers for logging: x-github-delivery, x-github-event, x-gitlab-event, and x-gitlab-event-uuid.
Verifying a webhook signature
Below is a reference implementation for the Linear scheme TofuPilot uses inbound, which you can adapt to your stack.
import hmac
import hashlib
import time
def verify(body: bytes, signature_header: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in signature_header.split(","))
timestamp = parts.get("t")
signature = parts.get("s")
if not timestamp or not signature:
return False
now_ms = int(time.time() * 1000)
if abs(now_ms - int(timestamp)) > 5 * 60 * 1000:
return False
payload = f"{timestamp}.".encode() + body
expected = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)import { createHmac, timingSafeEqual } from 'node:crypto';
export function verify(body, signatureHeader, secret) {
const parts = Object.fromEntries(
signatureHeader.split(',').map((p) => p.split('=', 2)),
);
const { t: timestamp, s: signature } = parts;
if (!timestamp || !signature) return false;
if (Math.abs(Date.now() - Number(timestamp)) > 5 * 60 * 1000) {
return false;
}
const expected = createHmac('sha256', secret)
.update(`${timestamp}.${body}`)
.digest('hex');
if (expected.length !== signature.length) return false;
return timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}Always verify against the raw request body, because re-serializing the parsed JSON does not reproduce the signed bytes.
Replay protection
The Linear handler rejects any request whose timestamp is more than 5 minutes from server time. GitHub and GitLab inbound webhooks rely on the signature, and replay protection there comes from the provider's delivery id (x-github-delivery, x-gitlab-event-uuid).
For your own endpoints, apply the same 5-minute window once outbound signing is available. Until then, you can deduplicate by an idempotency key.
How is this guide?