
# File Actions

> Sixteen operators over storage — moving files, reading their metadata, generating documents, and the closed spreadsheet subset.

File actions operate against the app's configured storage, whether that is the local filesystem or an object store.

## Storage

| Operator   | Props                             | Does                       |
| ---------- | --------------------------------- | -------------------------- |
| `upload`   | `source`, `path?`, `contentType?` | Uploads a file to storage  |
| `download` | `key`                             | Downloads a stored file    |
| `delete`   | `key`                             | Deletes a stored file      |
| `copy`     | `source`, `destination`           | Copies a stored file       |
| `move`     | `source`, `destination`           | Moves or renames a file    |
| `list`     | `prefix`, `limit?`                | Lists files under a prefix |

## Metadata and access

| Operator      | Props                             | Does                                            |
| ------------- | --------------------------------- | ----------------------------------------------- |
| `getMetadata` | `key`                             | Reads size, content type and the rest           |
| `signUrl`     | `key`, `expiresIn?`, `operation?` | Mints a time-limited URL for download or upload |

## Generation

| Operator       | Props                                                                                    | Does                            |
| -------------- | ---------------------------------------------------------------------------------------- | ------------------------------- |
| `generatePdf`  | `template`, `filename`, `data?`, `pageSize?`, `orientation?`, `margins?`, `destination?` | Renders an HTML template to PDF |
| `generateCsv`  | `data`, `filename`, `columns?`, `delimiter?`, `includeHeaders?`, `destination?`          | Writes a CSV from an array      |
| `generateXlsx` | `data?` or `sheets?`, `filename`, `columns?`, `sheetName?`, `destination?`               | Writes a workbook from rows     |

## Parsing and transforming

| Operator         | Props                                                                        | Does                                |
| ---------------- | ---------------------------------------------------------------------------- | ----------------------------------- |
| `parseCsv`       | `source?`, `key?`, `content?`, `columns?`, `skipRows?`, `delimiter?`         | Parses CSV into rows                |
| `parseXlsx`      | `source?`, `key?`, `sheet?`, `header?`, `range?`, `skipRows?`                | Parses a worksheet into rows        |
| `extractText`    | `source`, `format?`                                                          | Extracts text from a document       |
| `transformImage` | `source`, `width?`, `height?`, `fit?`, `format?`, `quality?`, `destination?` | Resizes or converts an image        |
| `compress`       | `files`, `filename`, `destination?`                                          | Zips several files into one archive |

| Path | Kind | Values | Default | Description |
| --- | --- | --- | --- | --- |
| `name` | string |  |  | Step name for referencing outputs (e.g., "fetchUser"). Must be alphanumeric + underscore. |
| `label` | string |  |  | Human-readable label for this action step |
| `continueOnError` | boolean |  | false | Continue workflow even if this action fails (default: false) |
| `timeout` | number |  |  | Per-action timeout in ms (1000-900000). Terminates the action when exceeded. |
| `type` | enum | `file` |  | Constant value 'file' for type discrimination in discriminated unions |
| `operator` | enum | `upload`, `download`, `delete`, `copy`, `move`, `list`, `getMetadata`, `signUrl`, `generateCsv`, `generatePdf`, `parseCsv`, `extractText`, `transformImage`, `compress`, `parseXlsx`, `generateXlsx` |  | Selects the operation within the 'file' action family; it decides which props the step takes |

### `retry`

| Path | Kind | Values | Default | Description |
| --- | --- | --- | --- | --- |
| `retry` | object |  |  | Automatic retry behavior for failed executions with fixed or exponential backoff |
| `retry.maxAttempts` | number |  |  | Maximum retry attempts (1-10) |
| `retry.delayMs` | number |  |  | Base delay between retries in milliseconds (100-60000, default: 1000) |
| `retry.strategy` | enum | `fixed`, `exponential` |  | Retry strategy: fixed delay or exponential backoff (default: fixed) |

### `props`

| Path | Kind | Values | Default | Description |
| --- | --- | --- | --- | --- |
| `props` | object |  |  |  |
| `props.source` | string |  |  |  |
| `props.path` | string |  |  | Storage key destination. If omitted, auto-generated. |
| `props.contentType` | string |  |  |  |
| `props.key` | string |  |  |  |
| `props.sourceKey` | string |  |  |  |
| `props.destinationKey` | string |  |  |  |
| `props.prefix` | string |  |  | Storage key prefix to list files under |
| `props.limit` | number |  |  | Maximum number of files to return |
| `props.expiresIn` | number |  |  | URL expiration time in seconds (default: 3600) |
| `props.operation` | enum | `download`, `upload`, `resize`, `convert` |  |  |
| `props.data` | string \| object |  |  |  |
| `props.filename` | string |  |  |  |
| `props.columns` | array |  |  |  |
| `props.columns[]` | object |  |  |  |
| `props.columns[].key` | string |  |  |  |
| `props.columns[].field` | string |  |  | Object key to extract as column value |
| `props.columns[].header` | string |  |  |  |
| `props.delimiter` | enum | `,`, `;`, `	`, `\|` |  |  |
| `props.includeHeaders` | boolean |  |  | Include column headers as first row (default: true) |
| `props.destination` | string |  |  | Storage key for the output file. If omitted, file is stored in temporary storage and auto-cleaned after STORAGE_TEMP_CLEANUP_AFTER (default: 24 hours). |
| `props.template` | string |  |  | HTML template for PDF content (supports template variables) |
| `props.pageSize` | enum | `A4`, `A3`, `Letter`, `Legal` |  | Page size (default: A4) |
| `props.orientation` | enum | `portrait`, `landscape` |  | Page orientation (default: portrait) |
| `props.margins` | object |  |  | Page margins |
| `props.margins.top` | string |  |  | Top margin (e.g., "1cm", "0.5in") |
| `props.margins.right` | string |  |  | Right margin |
| `props.margins.bottom` | string |  |  | Bottom margin |
| `props.margins.left` | string |  |  | Left margin |
| `props.content` | string |  |  | Inline CSV text to parse (alternative to `source`/`key`) |
| `props.columns[].name` | string |  |  | Object key for the parsed value |
| `props.columns[].index` | number |  |  | Zero-based CSV column index to read this value from |
| `props.skipRows` | number |  |  |  |
| `props.format` | enum | `plain`, `markdown`, `jpeg`, `png`, `webp` |  |  |
| `props.width` | number |  |  | Target width in pixels (1-2500) |
| `props.height` | number |  |  | Target height in pixels (1-2500) |
| `props.fit` | enum | `fill`, `inside` |  | How a two-dimension resize uses the box: `fill` stretches to exactly width x height (distorts); `inside` scales down to fit within the box, preserving the aspect ratio (output may be smaller than requested). Required when both `width` and `height` are set; ignored otherwise. |
| `props.outputFormat` | enum | `jpeg`, `png`, `webp` |  | Output image format (operation: convert) |
| `props.quality` | number |  |  | Output quality for lossy formats (1-100, default: 80) |
| `props.keys` | array |  |  | Array of storage keys to compress into the archive |
| `props.keys[]` | string |  |  | String with {{step.property}} variables, {{helper args}} expressions, and $env.VAR references |
| `props.files` | string |  |  | Template resolving to array of storage keys to compress |
| `props.sheet` | string \| number |  |  | Sheet to read: a sheet name, or a zero-based sheet index (default: first sheet) |
| `props.header` | boolean |  |  | Treat the first row as a header row — it becomes `columns` and is excluded from `data` (default: false) |
| `props.range` | string |  |  | A1-style range to read, e.g. "A1:C10" (default: the sheet's full used range) |
| `props.sheets` | array |  |  | Multi-sheet output, one entry per worksheet (alternative to `data`) |
| `props.sheets[]` | object |  |  |  |
| `props.sheets[].name` | string |  |  | Worksheet name as it appears on the tab |
| `props.sheets[].data` | string |  |  | Template variable referencing this sheet's rows |
| `props.sheets[].columns` | array (truncated) |  |  | Column definitions for this sheet |
| `props.sheetName` | string |  |  | Worksheet name for the single sheet (default: "Sheet1") |

## CSV

`parseCsv` needs exactly one input: `source` (a storage key), `key` (its alias), or `content` — inline CSV text, which parses a webhook body or an earlier step's output without a storage round-trip. Omitting all three is a configuration error.

`skipRows` drops that many leading non-blank lines and nothing else, so it strips a preamble without changing the shape of the output: the first line that survives is still read as the header, and rows stay keyed by header name.

`delimiter` is one of comma, semicolon, tab or pipe. Omitted, it is auto-detected by counting candidates **outside quoted fields** in the first surviving line, so a semicolon-delimited export whose header legitimately contains a comma still reads correctly. Pass `columns` to map explicitly instead, each entry taking a `name` plus either a `header` name or a zero-based `index`.

## Spreadsheets — a closed subset

`parseXlsx` and `generateXlsx` support a **named, closed subset** of the spreadsheet format rather than the format at large. On the way in that subset is: shared and inline strings, numbers, booleans, dates recognised through the cell's number format — an Excel date serial is otherwise indistinguishable from a plain number — and formulas read as their **cached value**, never evaluated. Dates come back as ISO 8601 strings rather than date objects, so they survive being persisted to run history and re-read by a template.

### The wall is at data outside the cell grid

A workbook carrying a chart, a drawing, an embedded image, a pivot table or a macro is **refused by name**. Reading it would hand back the cells and quietly drop the part of the document its author cared about, which is the worst available outcome: a plausible answer that is missing the point of the file.

Refusal is detected on both the archive's part paths and its content-type overrides, because the two can disagree. Cosmetic styling — fonts, fills, borders — is **not** refused but ignored, since refusing it would refuse essentially every real workbook. Outside the subset the action fails loudly and deliberately: convert the file rather than trust a wrong answer.

### Reading

`parseXlsx` takes the workbook as `source` or its alias `key`; a data URI and an `https` URL are also accepted. `sheet` selects a worksheet by name, or by zero-based position when given a number; omitted, the first sheet in the workbook's declared order is read. `range` restricts reading to an A1-style window, defaulting to the sheet's used range. `header` treats the first row as a header, promoting it to `columns` and excluding it from the data. `skipRows` then drops that many rows off the top of the grid.

The output carries the rows plus the sheet's name, every sheet name in the workbook, the row count and the columns. That list of names is what lets an automation discover a workbook's sheets on a first call and target one on a second.

```yaml
- name: importSheet
  type: file
  operator: parseXlsx
  props:
    source: '{{trigger.data.key}}'
    sheet: Orders
    header: true
    range: 'A1:F500'
```

### Writing

`generateXlsx` writes the minimal part set a consumer requires, adding a styles part only when a date cell is present. Pass `data` for a single sheet, naming it with `sheetName`, or `sheets` for several, each entry taking a name, its own data and optionally its own columns. `columns` selects and orders the fields and supplies their header labels.

The refusal here is per **cell value**: anything outside string, number, boolean and date — an object, an array, a bigint, a not-a-number, an invalid date — fails the step with an error naming the sheet and the cell reference, rather than being coerced into a plausible-looking string.

Round-tripping a generated workbook back through the parser is lossless **within** the subset, and only within it.

```yaml
- name: exportOrders
  type: file
  operator: generateXlsx
  props:
    data: '{{fetchOrders.records}}'
    filename: 'orders-{{trigger.data.month}}.xlsx'
    sheetName: Orders
    destination: exports/
```

## PDF and images

```yaml
- name: invoice
  type: file
  operator: generatePdf
  props:
    template: invoice-template
    filename: 'invoice-{{trigger.data.id}}.pdf'
    data: '{{trigger.data}}'
    pageSize: A4
    orientation: portrait
    destination: invoices/
```

```yaml
- name: thumbnail
  type: file
  operator: transformImage
  props:
    source: '{{upload.result.key}}'
    width: 320
    format: webp
    quality: 70
```

A conversion naming no format encodes to WebP, and a plain resize keeps the source format rather than transcoding it. That is the built-in behaviour rather than a setting — name a format on the action when a particular consumer needs another codec.

## Behaviour

### Compress Action

- Creates ZIP from array of storage keys
- With destination stores ZIP at specified key
- Without destination uses temp storage
- Result includes fileCount
- Nonexistent source key fails gracefully

### Copy Action

- Copy creates duplicate at destination key
- Copy preserves original at source key
- Copy to existing key overwrites destination

### Delete Action

- Delete by key removes file and metadata
- Delete nonexistent key returns error (respects continueOnError)

### Download Action

- Download by key returns metadata and stores content in temp key
- Download nonexistent key returns error
- Download result usable as source in subsequent file action

### Extract Text Action

- Extract from PDF returns plain text
- Markdown format returns structured markdown
- Result includes wordCount and pageCount
- Unsupported format returns error

### Generate CSV Action

- File generateCsv creates CSV from data array with configurable column mapping
- GenerateCsv with destination stores file at specified storage key
- GenerateCsv without destination uses temp storage with auto-cleanup
- GenerateCsv result contains key, filename, contentType, size
- GenerateCsv with delimiter and includeHeaders=false
- GenerateCsv result key is usable as source in subsequent action
- A value containing the configured delimiter is quoted on disk, not split
- Delimiter-bearing output round-trips: parseCsv reads back the original value

### Generate PDF Action

- File generatePdf creates PDF from HTML template with template variable substitution
- GeneratePdf with destination stores file at specified storage key
- GeneratePdf without destination uses temp storage with auto-cleanup
- GeneratePdf result contains key, filename, contentType, size
- GeneratePdf with pageSize, orientation, and margins
- GeneratePdf result key is usable as source in subsequent action

### Generate XLSX Action

- Writes a real OOXML package to `destination` — a ZIP container carrying the parts an `.xlsx` consumer requires
- Values survive a `generateXlsx` → `parseXlsx` round-trip with their types intact, not flattened to strings
- Honours an explicit `columns` selection and its `header` labels — an undeclared key never becomes a cell
- The `sheets[]` form emits every declared worksheet, under its declared `name`, in declaration order

### Get Metadata Action

- Returns file info without downloading content
- Nonexistent key returns error

### List Action

- List by prefix returns matching file metadata array
- List with limit restricts result count
- List empty prefix returns empty array

### Move Action

- Move relocates to destination and deletes source
- Move result contains new key
- Source key no longer exists after move

### Parse CSV Action

- Reads CSV from storage key, returns structured data array
- Custom columns maps CSV columns to object keys
- skipRows skips header/metadata rows
- Custom delimiter parses semicolon/tab files
- Result data usable in subsequent record batchCreate
- A newline inside a quoted field stays one row (RFC 4180)
- Quoted whitespace is preserved; unquoted cells still trim
- A semicolon file whose header contains one stray comma is detected as semicolon-delimited
- Inline `content` parses with no storage round-trip
- A `data:` URI source parses with no storage round-trip
- An `https://` source is fetched over the network, then parsed
- A windows-1252 export decodes its accented characters
- skipRows drops preamble lines and still yields header-keyed rows

### Parse XLSX Action

- Reads a workbook from a storage key, typing each cell as a string, a number or a boolean rather than as text throughout
- Resolves cells written as inline strings, not only those pointing into the shared-string table
- A serial carrying a date number format reads as a date, while the same bare serial stays a number
- A formula cell yields the workbook's cached value — the reader never re-evaluates the formula
- `sheet` given a name reads that worksheet rather than the first
- `sheet` given a zero-based integer selects positionally
- Reports every sheet name in workbook order, so a follow-up action can target one it discovered
- Refuses a workbook outside the supported OOXML subset by name, instead of misparsing it
- Refuses a file that is not a ZIP container at all

### Sign URL Action

- Generates time-limited download URL
- Respects custom expiresIn
- Upload operation generates upload-capable URL

### Temp Storage Cleanup

- A temp file older than `STORAGE_TEMP_CLEANUP_AFTER` is removed by the next temp write, while that write's own file survives
- The sweep never removes files outside `tmp/automations/`, regardless of age
- `STORAGE_TEMP_CLEANUP_AFTER=0` disables sweeping entirely

### Transform Image Action

- Resize creates resized image at destination
- A crop configuration is refused at validation
- Convert changes image format (png to webp)
- Quality setting controls output compression
- Without destination uses temp storage
- A two-dimension resize must name its `fit`
- An `avif` output format is refused at validation

### Upload Action

- Upload from base64 data with auto-detected contentType
- Upload from URL source to storage path
- Upload from previous step result key
- Upload to temp storage when no path specified
