Fob
Browse the docs

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
slugRequired. Unique across the worker. Stations and fob-worker refer to the step by it
name, descriptionRequired. Shown in the Orchestrator
inputSchemaOptional Zod schema for the config. Defaults are applied, and an invalid config fails the step before execute runs
outputSchemaOptional Zod schema for the return value. An invalid output fails the step
execute(config, context)Required. Does the work and returns the output
enabledOptional; 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 context#

context.
work_record.idThe run's work record id. Local runs get local-wr-…
work_record.step_outputsOutputs of the steps that already ran, keyed by slug
step.slug, step.configThis step, and its config before templates were resolved
org_idYour Orchestrator organisation. In a local run, WORKER_LOCATION, or local if it isn't set
step_queue_idThe 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.