Skip to content

xlsx://

Read and write .xlsx (Office Open XML Spreadsheet) workbooks via github.com/xuri/excelize/v2. Helix-native.

Shape-compatible with:

  • read() / write() over application/xlsx in Starlang
  • {marshal: xlsx} / {unmarshal: xlsx} in processor/dataformat.go
  • The XLSXParser in pkg/rag/ingest (renders each sheet as a Markdown table)

For Excel Online / Google Sheets (cloud APIs), use the microsoft/excel_online and google/sheets connectors. The hand-rolled connectors/file/excel was removed in this round — it didn't resolve rich-text shared strings, so Excel-emitted files (which use rich-text runs even without visible formatting) came back with blank text cells. xlsx:// uses excelize which handles every shared-string form the OOXML spec defines.

URI shape

xlsx:///abs/path/to/file.xlsx?[options]
xlsx://relative/path.xlsx?op=write&sheet=Data
xlsx://?file=/tmp/out.xlsx&op=read

The file path can be in the URI authority/path, in the file= URI parameter, or in the HelixXlsxFile header (per-message override). Spaces in paths may be URL-encoded (xlsx:///tmp/with%20space.xlsx).

Parameters

Param Type Default Description
file string — Path to the .xlsx. Optional when supplied by the URI path or HelixXlsxFile.
op enum read One of read / write / append / list_sheets. listSheets is accepted as a camelCase alias.
sheet string Sheet1 Sheet name to read or write. With the streaming consumer (stream=true), sheet=* reads every sheet in the workbook, in order. Ignored by op=list_sheets.
hasHeader boolean true Treat row 1 as the column-header row on read; emit one on write from the first body row's keys. Accepts forgiving forms: true/false, 1/0, yes/no, on/off.
maxRows integer 0 Cap data rows on read (0 = unlimited).
maxCols integer 0 Cap columns on read (0 = unlimited).

Operations

op Input Body() Output Body() Notes
read (default) nil []map[string]any (when hasHeader=true) or [][]string Honors maxRows / maxCols.
write []map[string]any / [][]string / [][]any / map[string]any / []map[string]string / []any unchanged (sets HelixXlsxFileWritten) Overwrites the file. Header row auto-emitted from the first row's keys when hasHeader=true. Add stream=true for constant-memory bulk writes of large row sets.
append same as write same as write Appends after the last used row of sheet. If the file or sheet doesn't exist, behaves like write (idempotent first-write).
list_sheets nil []string Returns the workbook's sheet names.

Body shapes accepted on write/append

  • []map[string]any — each map's values become one row; keys are sorted to form the header row.
  • []map[string]string — same.
  • map[string]any — single row.
  • [][]any / [][]string — raw rows (no header inference).
  • []any — the shape the Starlang→Go bridge produces for a JSON array, so set-body: { starlang: 'read(payload.data_json, "application/json")' } writes a whole batch in one call. Elements must be uniform: all objects (map[string]any → header mode) or all arrays ([]any → raw-matrix mode); a mixed or scalar-element []any is a clear error.
  • nil — empty workbook with only the configured sheet.

For very large writes, add stream=true to op=write: rows go through excelize's StreamWriter (constant memory, one pass) instead of building the whole workbook in memory with per-cell SetCellValue — e.g. xlsx:///out/report.xlsx?op=write&stream=true. (A 200k-row write drops from ~590 MiB to ~200 MiB peak; the gap widens with size.)

Headers

Per-message overrides (read on In):

Header Overrides
HelixXlsxFile file
HelixXlsxOp op
HelixXlsxSheet sheet
HelixXlsxHasHeader hasHeader (accepts the same forgiving forms)

Producer outputs (set on In):

Header When Meaning
HelixXlsxRowCount read / write / append Rows read / written / appended.
HelixXlsxSheetCount list_sheets Number of sheets returned.
HelixXlsxFileWritten write / append Absolute path of the file the producer wrote to.

Examples

Read a sheet

workflows:
  - id: read-orders
    trigger:
      uri: timer://every?period=5m
    steps:
      - target: "xlsx:///data/orders.xlsx?sheet=Orders&maxRows=1000"
      - log:
          level: info
          message: "loaded ${header.HelixXlsxRowCount} order rows"

Append per-message

workflows:
  - id: log-events
    trigger:
      uri: kafka://events
    steps:
      - set-header:
          name: HelixXlsxOp
          constant: append
      - transform:
          starlang: '[{"ts": now(), "event": payload}]'
      - target: "xlsx:///var/log/events.xlsx?sheet=Events"

List sheet names

workflows:
  - id: probe
    trigger:
      uri: direct://probe
    steps:
      - target: "xlsx:///tmp/workbook.xlsx?op=list_sheets"
      # body is now []string of sheet names

Marshal/unmarshal via dataformat

If you already have rows in the exchange and want raw .xlsx bytes (for an HTTP attachment, S3 PutObject, etc.):

- marshal:
    type: xlsx
- target: "s3://my-bucket?key=report.xlsx"

Or the reverse, parsing an .xlsx from an HTTP request body:

- unmarshal:
    type: xlsx
# body is now []map[string]any

RAG ingest

.xlsx files dropped into a rag-ingest step are now picked up automatically — each sheet renders as a ## Sheet: <name> heading followed by a Markdown table.

Consumer (xlsx:// as trigger)

xlsx:// is also usable as a workflow trigger. The consumer polls the file on a configurable cadence and dispatches an exchange when the file changes (or unconditionally, depending on fireOn).

Consumer parameters

Param Type Default Description
delay duration 30s Interval between polls.
initialDelay duration 1s Wait before the first poll.
repeatCount integer 0 Cap on number of polls (0 = infinite).
fireOn enum changed changed = dispatch only when the file's mtime advanced since the last fire; always = dispatch every poll.
splitRows boolean false When true, dispatch one exchange per row (body = map[string]any per row). Default fires once per poll with the whole sheet.
stream boolean false Stream the sheet row-by-row (excelize f.Rows) and dispatch rows in batches of batch, instead of materialising the whole sheet with GetRows. Enables constant-memory reads of very large (1M+ row) workbooks without tripping the 30s single-eval cap. Use sheet=* to stream every sheet in order.
batch integer 1000 Rows per dispatched exchange when stream=true (0 → 1000). Bigger batches mean fewer exchanges but more rows per downstream evaluation. splitRows=true forces one row per exchange.

Examples

Default fireOn=changed — only refires when the file is rewritten:

workflows:
  - id: react-to-file-change
    trigger:
      uri: "xlsx:///data/feed.xlsx?delay=10s&sheet=Live"
    steps:
      - log:
          level: info
          message: "feed changed; ${header.HelixXlsxRowCount} rows"
      - target: direct://process-feed

Row-by-row processing — like Kafka but for a spreadsheet:

workflows:
  - id: stream-rows
    trigger:
      uri: "xlsx:///data/orders.xlsx?delay=5s&splitRows=true&fireOn=changed"
    steps:
      - transform:
          starlang: 'payload.amount * payload.qty'
      - target: kafka://orders-totals

Stream a very large workbook (1M+ rows, constant memory) — reads row-by-row and hands downstream one batch of rows at a time, so nothing materialises the whole sheet and each batch is its own evaluation (so it never hits the 30s single-eval cap that a whole-sheet read() would):

workflows:
  - id: ingest-huge-workbook
    trigger:
      uri: "xlsx:///data/orders_1M.xlsx?stream=true&batch=10000&sheet=*&fireOn=always&repeatCount=1"
    steps:
      - log:
          level: info
          showBody: false   # don't echo the (large) batch body
          message: "sheet=${header.HelixXlsxSheet} batch=${header.HelixXlsxBatchIndex} rows=${header.HelixXlsxRowCount}"
      - target: "sql://orders/insert?driver=postgres&dsn=${env:PG_DSN}&batch=true"  # each batch inserted independently

Each dispatched exchange carries three out-headers: HelixXlsxSheet (the source sheet), HelixXlsxBatchIndex (0-based, monotonic across sheets when sheet=*) and HelixXlsxRowCount (rows in that batch) — surface them in a log via ${header.HelixXlsxSheet} (as above) or read them in Starlang as headers["HelixXlsxSheet"] for routing. maxRows caps the total across the whole stream; maxCols truncates each row.

Periodic re-read regardless of mtime (use sparingly — slower than fireOn=changed):

workflows:
  - id: poll-fresh
    trigger:
      uri: "xlsx:///data/live.xlsx?delay=1m&fireOn=always"
    steps:
      - target: direct://downstream

Bounded run (fire 10 times then stop — useful for batch jobs):

workflows:
  - id: batch
    trigger:
      uri: "xlsx:///data/in.xlsx?delay=2s&repeatCount=10&fireOn=always"
    steps:
      - target: direct://process

The consumer is suspendable: Suspend() halts dispatching without tearing down the goroutine, and Resume() un-pauses. Useful for incident pause/resume flows from the management API.

Validation

helix validate checks every xlsx:// URI's params against the schema:

  • Unknown keys produce a warning (with the valid-key list).
  • op values outside [read, write, append, list_sheets] produce an error and a non-zero exit.

The JSON Schema lives at schemas/components/xlsx.json.