# Steps

> Every step type a workflow can use, the templates that wire them together, and the code that runs inside them.

Source: https://docs.fous.com/guides/workflows/steps
Index: https://docs.fous.com/llms.txt

Every step has a `name` (plain words), a `type` and a `key`. If you omit `key`, the server generates it from the name (`List invoices` becomes `list_invoices`). Templates and code read a step's output by its key.

| `type` | Takes | Output |
| --- | --- | --- |
| [`method`](#method) | `method` (a slug), `input`, `connection`, `on_error` | `{ status, data, run_id, error }` |
| [`ai`](#ai) | `prompt`, `fields` (optional) | `{ text }`, or its fields |
| [`transform`](#transform) | `instruction` (one sentence), `code` (JavaScript) | The value the code returns |
| [`filter`](#filter) | `conditions`, `match` (`all` by default, or `any`) | `{ passed }` |
| [`loop`](#loop) | `over` (a template that gives a list), `max` (10 by default, any number), `steps` | `{ count, items }` |
| [`delay`](#delay) | `seconds`, 1 to 86,400 | `{ seconds }` |
| [`email`](#email) | `to`, `subject`, `body` | `{ sent }` |
| [`webhook`](#webhook) | `url`, `body`, `headers` (optional) | `{ status, body }` |

Use each step type for its purpose. A transform does exact data work that is the same on every run: filtering by a list of values, removing duplicates, sorting, keeping the top five, comparing with the last run. An AI step does work that needs reading or judgment: summarizing, classifying, extracting values from free text, writing a message. A filter decides whether the remaining steps run.

A workflow can have any number of steps, within a definition of at most 200 KB. A run is limited only by your credits and each loop's `max`.

## Method

Runs one of your methods. `connection` (a `conn_` ID, or `null` for the site's default) selects the account it runs as. A failed step fails the run unless `on_error` is `continue`; then its output has `status: "failed"` with the `error`, and the next steps run. A method's result is `data` in its output, so a later step reads `{{steps.invoices.data.invoices}}`.

## AI

A prompt in plain words that a model answers on every run. Templates in `prompt` read the data it works on.

```json
{
  "key": "summary",
  "name": "Summarize the invoices",
  "type": "ai",
  "prompt": "Write a short digest of these invoices, grouped by supplier. Keep it under 300 words.\n\n{{steps.invoices.data.invoices}}"
}
```

A list, an object or a long text that a template reads is sent to the model as the prompt's data, named by its template, so long data is shortened like any data and your instructions are never cut. Short values, such as `{{steps.invoices.data.invoices | length}}`, are written into the prompt. The older `input` field still works and is sent as data too.

The output is `{ text }`, so an email body reads `{{steps.summary.text}}`. With `fields` (up to 10), the output has one short text per field instead: `"fields": ["urgency", "team"]` gives `{ "urgency": "High", "team": "Billing" }`, read as `{{steps.classify.urgency}}`.

An AI step reads about 30 KB of prompt and data and writes up to about 800 words. Longer data has its longest values shortened first, so every item is kept. If that is not enough, the last items are dropped, and the step's `input` in the run reports `shortened` and `left_out`. If the model declines to answer or the answer is too long, the step fails with the reason. An AI step costs 1 credit like any other step, and does not count as an [AI message](https://docs.fous.com/guides/billing.md#builds-and-ai).

## Transform

A one-sentence instruction and the JavaScript that implements it. Save a transform with an `instruction` and empty `code`, and the code is written when the workflow is saved. Every run then executes the same code, with no AI call. If you change the `instruction` and send `code` unchanged, the code is rewritten; code you edit yourself is kept as written. `POST /v1/workflows/ai-code` with `{ instruction, definition, step_key }` returns `{ code }` without saving.

The code is the body of `function (data, stop)` and returns the step's output, which can be any JSON value:

```js
const invoices = data.steps.invoices.data.invoices.filter((invoice) => invoice.total > 1000);
if (!invoices.length) stop('Nothing above 1,000 today.');
return invoices.map((invoice) => `${invoice.number}: ${invoice.total}`).join('\n');
```

- `data.trigger` is the run's input, `data.steps.<key>` is the output of each earlier step, `data.loop` is `{ item, index }` inside a loop, `data.previous` is the [last run](#changes-since-the-last-run), and `data.state` is the step's [memory across runs](#new-items-since-the-last-run).
- `stop(reason)` ends the run without failing it, like a filter that does not pass. Inside a loop it skips the rest of that item.
- The code runs in an isolated sandbox with no network, files, modules or timers, for at most 2 seconds and 64 MB. It is synchronous, `Date` works in UTC, and there is no `Intl`. Code that throws or times out fails its step with the error and line number.

## Filter

Continues only if its conditions pass. Each condition reads a value with a template, and `match` sets whether `all` conditions (the default) or `any` of them must pass. A filter has at most 10 conditions.

```json
{
  "key": "any_new",
  "name": "Only if there are invoices",
  "type": "filter",
  "conditions": [
    { "value": "{{steps.invoices.data.invoices | length}}", "op": "gt", "compare": "0" }
  ]
}
```

| `op` | Passes when the value |
| --- | --- |
| `exists`, `not_exists` | is present, or is empty (`null`, `""`, `[]` or `{}`) |
| `equals`, `not_equals` | equals `compare`, as case-insensitive text or as a number |
| `contains`, `not_contains` | contains `compare` in its text, or as an item of a list |
| `gt`, `gte`, `lt`, `lte` | compares with `compare` as a number; a list compares by its length |

When a filter does not pass, the remaining steps are skipped and the run still succeeds. The step's status is `filtered`, and its output gives the reason: `{ "passed": false, "message": "Stopped: Only if there are invoices (0 is not more than 0)." }`. Inside a loop, a filter skips the rest of that item.

## Loop

Runs its `steps` once per item of `over`, for at most `max` items: 10 by default (5 on Free), or any number you set. Each item's action steps cost credits. Inside the loop, `{{loop.item}}` is the item and `{{loop.index}}` is its position, from 0. A text value is split at commas and new lines, so `"DE, FR, US"` is three items. The output's `items` holds each item's step outputs, by key.

So that one failing item does not stop the others, give its method step `"on_error": "continue"` and, when later steps in the loop use its data, put a filter on `{{steps.<key>.status}}` equal to `completed` right after it: a failed item skips the rest of its steps and the loop goes on.

## Delay

Pauses the run for `seconds` (1 to 86,400) without occupying a worker.

## Email

Sends one plain-text email to the addresses in `to`, separated by commas. Every field accepts templates. It is sent from `workflows@fous.com` as "`<workflow name>` via Fous", and replies go to the person who created the workflow.

On Free, Starter and Pro, workflow emails can go only to people in your organization. On Business and Enterprise, they can go to anyone. `workflows.external_email` in [`GET /v1/account`](https://docs.fous.com/guides/billing.md#account-and-spending) shows which applies to your plan. An address outside your organization is rejected when the workflow is saved, or when the run reaches the step if a template produces the address.

## Webhook

POSTs JSON to a public HTTPS URL. `body` is a template or an object of templates; if omitted, the previous step's output is sent. URLs on a private network are rejected and redirects are not followed. The step waits up to 10 seconds for a response and keeps up to 64 KB of its body. A status outside 2xx fails the step, with the response in its output. Every attempt carries the same `Idempotency-Key` header, plus `X-Fous-Workflow`, `X-Fous-Run` and `X-Fous-Signature`.

### Headers

For a receiver that needs a key or a token, `headers` adds up to 10 headers to every call:

```json
{ "headers": [{ "name": "Authorization", "value": "Bearer sk_live_…" }] }
```

A header's value is a secret, kept like a saved password. It is encrypted at rest and only sent with the step's call. Answers, runs and the workflow assistant never see it; they get `{ "name": "Authorization", "hint": "••••••••3f9a" }`, with `hint: null` while there is no value. To keep the saved value, send the header without `value`. Send `"value": ""` to clear it. A header without a value makes the workflow [incomplete](https://docs.fous.com/guides/workflows/create.md) (`field: "headers.Authorization"`) until it has one. Fous sets `Host`, `Content-Type`, `Content-Length`, `Idempotency-Key`, `Connection`, `Transfer-Encoding` and `X-Fous-*` itself, so those names are refused.

### Verify a webhook

`X-Fous-Signature` is `t=<unix seconds>,v1=<hex>`, where `v1` is the HMAC-SHA256 of `<t>.<body>` with the workflow's `signing_secret` (in [`GET /v1/workflows/{id}`](https://docs.fous.com/guides/workflows/create.md#change-a-workflow), and in the step in Studio). Compare it with your own in constant time, and refuse a `t` that is too old.

```js
const [t, v1] = (header ?? '').split(',').map((part) => part.split('=')[1]);
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
const fresh = Math.abs(Date.now() / 1000 - Number(t)) < 300;
const ok =
  fresh &&
  v1?.length === expected.length &&
  crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
```

`POST /v1/workflows/{id}/signing-secret` gives the workflow a new secret; the old one stops working at once.

## Templates

Text in a method's input, an AI step's prompt, a filter's conditions, a loop's `over`, an email and a webhook can read the trigger and earlier steps with `{{ path | filter }}`.

| Template | Reads |
| --- | --- |
| `{{trigger.status}}` | The run's input. |
| `{{steps.invoices.data.invoices.0.number}}` | A step's output; numbers index lists. |
| `{{steps.top_five}}` | A transform's whole output. |
| `{{steps.summary.text}}` | An AI step's answer. |
| `{{loop.item}}`, `{{loop.index}}` | Inside a loop. |
| `{{run.id}}`, `{{run.started_at}}`, `{{now}}` | The run, and the current time. |
| `{{previous.steps.invoices.data}}` | The [last run](#changes-since-the-last-run)'s output. |

A text that is exactly one template keeps the value's type: `"{{steps.invoices.data.invoices}}"` is the list itself. Anywhere else, the value is inserted into the text, with lists and objects as JSON. A path that does not exist resolves to `null`, which inserts nothing into text.

Filters apply left to right: `upper`, `lower`, `trim`, `length`, `json`, `first`, `last`, `join:", "`, `default:"none"`, `number`, `round:2`, `slice:0:5` and `pluck:"name"` (a list of objects to a list of one field). `\n` in a quoted argument is a new line.

## Changes since the last run

In a transform, `data.previous` is the workflow's last completed run before this one: `{ run_id, finished_at, trigger, steps }`, or `null` on the first run. `steps` has each top-level step's output from the latest run where that step completed, so a run a filter ended early does not blank the steps it never reached. Templates read it as `{{previous.steps.<key>…}}`. Failed runs are never used for comparison.

```js
const before = new Map((data.previous?.steps.prices.data.items ?? []).map((i) => [i.sku, i.price]));
return data.steps.prices.data.items.filter((i) => before.has(i.sku) && i.price < before.get(i.sku));
```

On the first run there is nothing to compare with, so everything counts as new.

## New items since the last run

To act only on new items, a transform remembers what it has seen in `data.state`. This is its own object, `{}` on the first run, and the next run starts from it as the code left it. Unlike comparing with the last run, it does not forget an item because one run found nothing or the item moved to another page.

```js
const invoices = data.steps.invoices.data.invoices;
const seen = new Set(data.state.seen ?? []);
data.state.seen = [...new Set([...invoices.map((i) => i.number), ...seen])].slice(0, 1000);
return invoices.filter((i) => !seen.has(i.number));
```

A filter after it ends the run when the list is empty. The state is kept only when its run completes, so a run that failed before delivering its items does not mark them as seen. It holds at most 256 KB and belongs to top-level transforms; inside a For each it starts empty and is not kept. When you ask for "only new" items, the AI writes this code for you.
