xlsx://¶
Read and write .xlsx (Office Open XML Spreadsheet) workbooks via
github.com/xuri/excelize/v2. Helix-native.
Shape-compatible with:
read()/write()overapplication/xlsxin Starlang{marshal: xlsx}/{unmarshal: xlsx}inprocessor/dataformat.go- The
XLSXParserinpkg/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, soset-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[]anyis 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.):
Or the reverse, parsing an .xlsx from an HTTP request body:
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).
opvalues outside[read, write, append, list_sheets]produce an error and a non-zero exit.
The JSON Schema lives at
schemas/components/xlsx.json.