arcflow GitHub

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.scheduleStart by hand, from an HTTP call, or on a cron schedule. Each takes a sample payload for runs started by hand.
http.request, http.respondCall APIs (with credentials, timeouts, an error output); answer the webhook caller. In test runs the request step sends only GET and HEAD.
code.javascriptJavaScript in a QuickJS sandbox: no network or file access, 1 s of CPU and 32 MB of heap by default.
data.setBuild an object from values and expressions.
logic.if, logic.switch, logic.mergeBranch on conditions or values; wait for branches and combine them.
logic.loop, logic.wait, flow.callRepeat 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 item run once per item, with a concurrency limit; done continues 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 error port for the ones that can fail.
  • Sub-flows — a step can run another flow and continue with its result.