
# 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.

```js
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](https://zod.dev/) 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](/docs/workers/troubleshooting#unknown-step).

### 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>}}"`.

```json
{ "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.

<Callout type="warning" title="Keep secrets out of configs">
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.
</Callout>

## 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 |

```js
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:

```js
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](/docs/workers/workpieces).

## Test it

```bash
fob-worker steps run IN1_01_check --scenario two-invoices
```

See [Run steps locally](/docs/workers/run-steps). The template also has Jest set up (`npm test`) for unit tests of your own functions.
