Flows and steps
A flow is plain JSON: steps with settings, and connections between their output ports. Nothing in it is specific to the canvas — the same document runs headless, on a server, or inside another flow.
{
"version": 1,
"name": "Monthly payroll",
"nodes": [
{ "id": "start", "kind": "trigger.schedule", "config": { "cron": "0 9 1 * *" } },
{ "id": "pay", "kind": "http.request", "config": { "method": "POST", "url": "https://api.example.com/payouts" } }
],
"edges": [{ "from": "start", "to": "pay" }]
}Steps
A step declares what it needs with the f.* schema. That one declaration gives you the TypeScript type, runtime validation with paths, a form in
the editor, and JSON Schema for models.
import { defineNode, f } from '@arcsig-labs/core';
export const transfer = defineNode({
kind: 'action.transfer',
title: 'Send money',
description: 'Pays a list of people from the treasury.',
config: {
token: f.enum(['USDC', 'EURC'], { default: 'USDC' }),
recipients: f.list({ to: f.string(), amount: f.number({ min: 0 }) }, { minItems: 1 }),
memo: f.text({ optional: true }),
key: f.credential('treasury-key')
},
outputs: [{ id: 'out' }, { id: 'error', label: 'Error' }],
summary: (c) => `${c.recipients.length} payouts in ${c.token}`,
async run(ctx) {
const receipt = await pay(ctx.config, ctx.secrets.key); // ctx.config is typed
return { output: receipt, message: `Sent ${receipt.total}` };
},
simulate: (ctx) => ({ output: { total: ctx.config.recipients.length }, message: 'Would send' })
});simulate is what test runs use, so a flow can be exercised end to end without side effects. summary is the line under the step's
name on the canvas.
Standard steps
@arcsig-labs/nodes has thirteen steps to start from; your own packs sit next to them in the same registry.
import { createRegistry } from '@arcsig-labs/core';
import { standardSteps } from '@arcsig-labs/nodes';
const registry = createRegistry([standardSteps, myPack]);| Kind | |
|---|---|
trigger.manual, trigger.webhook, trigger.schedule | Start by hand, from an HTTP call, or on a cron schedule. Each takes a sample payload for runs started by hand. |
http.request, http.respond | Call APIs (with credentials, timeouts, an error output); answer the webhook caller. In test runs the request step sends only GET and HEAD. |
code.javascript | JavaScript in a QuickJS sandbox: no network or file access, 1 s of CPU and 32 MB of heap by default. |
data.set | Build an object from values and expressions. |
logic.if, logic.switch, logic.merge | Branch on conditions or values; wait for branches and combine them. |
logic.loop, logic.wait, flow.call | Repeat per item, pause, run another flow. |
Expressions
Any setting can be an expression. They are parsed, not evaluated — there is no eval anywhere.
{{ steps.check.output.balance }} // another step's output
{{ input.email ?? "team@example.com" }} // fallback when missing
{{ steps.list.output.items | length }} // filters after a pipe
{{ $item.name }} / {{ $index }} // inside a loop
{{ vars.threshold }} · {{ trigger.body }} · {{ run.id }}Validation
Every problem has a stable code and a JSON path, which is what makes repair loops reliable — for a person fixing a field, or a model fixing its own output.
const result = steps.parse(untrustedJson);
if (!result.ok) {
for (const issue of result.issues) {
console.log(issue.level, issue.code, issue.path, issue.message);
// error required nodes[1].config.url URL is required.
}
}What the engine handles
- Branching and joins — a step can wait for the first branch to arrive or for every branch to finish or be skipped.
- Loops — steps after
itemrun once per item, with a concurrency limit;donecontinues with the results. - Waiting — a step can pause the run for a timer or an approval; the state is JSON, so it survives a restart.
- Retries and timeouts per step, and an
errorport for the ones that can fail. - Sub-flows — a step can run another flow and continue with its result.