Write steps
Every .js file under src/steps/ (at any depth, except files named index.js) whose default export is a defineStep() is a step. There's no registry to update; the worker and fob-worker discover them.
import { defineStep } from '@fob/lib-worker';
import { z } from 'zod';
export default defineStep({
slug: 'IN1_01_check',
name: 'Check invoices',
description: 'Flags invoices above the approval limit',
inputSchema: z.object({ approval_limit: z.number().default(5000) }),
outputSchema: z.object({ checked: z.number() }),
execute: async (config, context) => {
// …
return { checked: 0 };
},
});
| Field | |
|---|---|
slug | Required. Unique across the worker. Stations and fob-worker refer to the step by it |
name, description | Required. Shown in the Orchestrator |
inputSchema | Optional Zod schema for the config. Defaults are applied, and an invalid config fails the step before execute runs |
outputSchema | Optional Zod schema for the return value. An invalid output fails the step |
execute(config, context) | Required. Does the work and returns the output |
enabled | Optional; false hides the step from discovery |
A file that fails to load (for example, missing description) is skipped with a [Worker] Failed to import warning, and the step then shows as unknown. See Troubleshooting.
Naming#
The template's convention: one folder per station, src/steps/<SHORT_CODE>__<station_name>/, and one file per step, <SHORT_CODE>_<NN>_<name>.js, with the same slug. The order steps run in comes from the station file, not from the numbers.
The config#
config is the step's config from the station file (or from the scenario you pass to fob-worker), after templates are resolved and inputSchema is applied.
A config value can refer to an earlier step's output in the same run: "{{<slug>.<field>}}".
{ "flagged": "{{IN1_01_check.above_limit}}" }
When the whole value is one template, its type is kept (here, an array). Inside a longer string, the value is inserted as text.
The worker logs each step's config. Read API keys and passwords from process.env inside execute, from the worker's .env, rather than putting them in station configs.
The context#
context. | |
|---|---|
work_record.id | The run's work record id. Local runs get local-wr-… |
work_record.step_outputs | Outputs of the steps that already ran, keyed by slug |
step.slug, step.config | This step, and its config before templates were resolved |
org_id | Your Orchestrator organisation. In a local run, WORKER_LOCATION, or local if it isn't set |
step_queue_id | The Orchestrator's id for this step run |
execute: async (config, { work_record }) => {
const { checked } = work_record.step_outputs['IN1_01_check'];
// …
}
Reports and documents#
Attach a markdown report or documents to the work record so people can see what the run did:
import { defineStep, attachReport, attachDocument, attachFile } from '@fob/lib-worker';
execute: async (config, { work_record, step }) => {
await attachReport(work_record.id, '# Invoice check\n\n2 checked, 1 above the limit.');
await attachDocument(work_record.id, 'Flagged invoices', 'INV-1002', step.slug);
await attachFile(work_record.id, 'Statement', '/path/to/statement.pdf', step.slug);
// …
}
Each also writes a copy under temp/work_records/<id>/. In a local run (local-wr-…), that copy is all that happens; nothing is sent. To render a report from a template file next to the step, use renderLocal(import.meta.url, './report.md', data) (EJS).
Failing#
Throw an error to fail the step. The worker reports the message, the work record shows it, and the Orchestrator retries the step up to three times. A step that processes many workpieces usually shouldn't throw for one bad workpiece; see Workpieces and bins.
Test it#
fob-worker steps run IN1_01_check --scenario two-invoices
See Run steps locally. The template also has Jest set up (npm test) for unit tests of your own functions.