Ctrl K
ComunidadGuías…

specs/077-xlsx.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# Spec: XLSX Actions

## Status

Implemented.

Conformance covers every statement below that a run of the `dagu` binary
can observe: the stdout line and the `warning:` and `problem:` streams,
path forms, every `range` and `header` form with the header warnings,
table detection, typing, formulas and error cells, empty rows, `where`,
`max_rows` with its default and the output budget, the column profile of
`xlsx.info` with its caps and the warning cap, hidden sheets and rows with
`skip_hidden` on read, validate, and convert, `rows` and every
`input` file kind with its encodings and the byte order mark, write modes
and `types` on write, sheet preservation, append, update_rows with its
matching rules, modes, shape checks, the foreach aggregate, concurrent
foreach items updating one workbook, and every error, the lock file,
artifacts including a DAG with artifacts disabled,
validate with every problem kind, `max_problems`, the stderr lines, and
`on_problem: fail`, the human task that follows a validation, write_cells
with its value forms, `merge`, into a copy, in a foreach, and every error,
merged cells met by appends, updates, and write_cells, sheet
operations with every mode and error, convert to csv, json, and jsonl
with every encoding and error, every `dagu dry` check, every validation
message in Errors, the `dagu xlsx` commands with their flags and text
formats, and extract with a scripted model: the cells the model names are
read with their types, including a merged region and Japanese amounts,
dates, times, and yes/no marks under a pinned type, a repeated layout
with the same instruction and schema
makes no model request, a form of the same template with a box left
blank is read from the cache, a changed layout, a changed schema, or a
label renamed in place asks again, the cell cap and the listing size cap
stop the step before any request, `send_values: false` keeps values out
of the request, a secret in the instruction stops the step before any
request, and `dagu xlsx cache clear` and `dagu rm
--history` drop the cached cells. Every workflow in Examples has a
fixture.

Conformance exceptions, behavior that a black-box run cannot observe or
set up and that unit tests of `internal/cmn/workbook` cover instead:
`with.password` on an xlsx step (nothing in Dagu writes a protected workbook;
opening one is covered by `dagu xlsx` and the workbook read target); the styles,
hyperlinks, merged regions, and tables that `mode: replace` clears,
`style: table` formatting, `formats`, style copying, and column widths (not
readable through `xlsx.read`; that a replace empties the values is covered); the
temporary file of an
atomic save and symbolic links; a lock file another process holds,
`wait_for_unlock` timing, and Windows sharing violations; the line a write
logs while it waits for another write of the same workbook, and aliases of
one workbook through symbolic links or case; the consequences of a rename
or delete for formulas and defined names and what a copy
leaves behind (library behavior); the dry check leaving an unopenable
workbook to the run; `.xls` and `.ods` beyond the extension check. Which
cell a real model names is not a conformance matter: the scripted model answers
deterministically, and the suite proves what the engine does with an
answer. The MCP `workbook` target is covered by the Spec 021 suite.

## Scope

This spec defines the `xlsx.read`, `xlsx.info`, `xlsx.list_sheets`,
`xlsx.write`, `xlsx.append`, `xlsx.update_rows`, `xlsx.validate`,
`xlsx.write_cells`, `xlsx.sheet`, `xlsx.convert`, and `xlsx.extract`
actions, the `dagu xlsx inspect`, `dagu xlsx read`, and `dagu xlsx cache
clear` commands, the MCP `workbook` read target, and the checks `dagu dry`
runs for xlsx steps. It covers configuration, validation, how cells become
values, how values become cells, published outputs, change summaries,
atomic saves, locked workbooks, and what a model is shown and asked when a
step locates fields on a form.

Out of scope: driving a spreadsheet application, the `.xls` and `.ods`
formats, macros, charts, images, pivot tables, conditional formatting, the
workbook's own data validation rules, Google Sheets, file locking on synced
folders, and streaming writes for sheets beyond what memory holds. Charts,
images, and formatting the actions do not touch are preserved on save.

## Goal

A workflow can take a workbook as input, act on each row, and write results
back into the same workbook, without a spreadsheet application and without a
script, and a rerun is safe: rows already done are skipped and a sheet that
changed shape under the workflow is refused rather than written to.

## Behavior

### Operations

| Action | Purpose | Outputs |
| --- | --- | --- |
| `xlsx.read` | Rows of a sheet, range, named range, or table. | `rows`, `count`, `headers`, `sheet`, `range`, `warnings`, `truncated` |
| `xlsx.info` | Sheets with used range, detected table, headers, column types, a profile of each column, row count, and tables; named ranges; date system. | `path`, `date_system`, `sheets`, `named_ranges`, `warnings` |
| `xlsx.list_sheets` | Sheet names in order. | `sheets`, `count` |
| `xlsx.write` | Create a workbook or write a sheet from rows. | `path`, `sheet`, `changes`, `dry_run`, `warnings`, `artifact` |
| `xlsx.append` | Add rows below the last used row. | Same as `xlsx.write` |
| `xlsx.update_rows` | Write columns back to rows selected by key or `_row`. | Same as `xlsx.write` |
| `xlsx.validate` | Check rows against rules and publish every problem. | `ok`, `problems`, `count`, `rows`, `headers`, `sheet`, `range`, `warnings`, `truncated` |
| `xlsx.write_cells` | Fill named cells of a workbook or of a copy. | Same as `xlsx.write` |
| `xlsx.sheet` | Add, copy, rename, or delete a sheet. | Same as `xlsx.write`, plus `sheets` |
| `xlsx.convert` | Export a sheet to csv, json, or jsonl. | `path`, `format`, `count`, `sheet`, `range`, `warnings`, `artifact` |
| `xlsx.extract` | Fields of a form-like sheet, located by a model and read from the cells it names. | One output per schema property, plus `cells`, `sheet`, `warnings`, `source` |

Outputs are fixed: declaring `output`, `outputs`, or `stdout.outputs` on an
xlsx step is rejected at validation. The executor writes one line to stdout
and one `warning:` line per warning to stderr; `xlsx.validate` also writes
one `problem:` line per problem. These are the step's own streams, so a
step's `stdout:` and `stderr:` file redirects (Spec 013) capture them.

Only `.xlsx` and `.xlsm` paths are accepted. `path` resolves like the file
actions: absolute and `~` paths as written, relative paths against the step
working directory. A `password` opens a protected workbook. A protected
workbook opened without that password, or with the wrong one, fails with
`<name>: workbook password is missing or incorrect`. Only ECMA-376 agile
and standard encryption can be opened; any other mechanism fails with
`<name>: workbook encryption is not supported`.

### Addressing

- `sheet` names the sheet; the first sheet by default. Names match exactly,
  then case-insensitively when that is unique. A miss lists the sheets present.
- `range` is `A2:F200`, `A2:F` or `A:F` (open end at the last used row), a
  single cell, `Sheet1!A2:F` or `'My Sheet'!A2:F` (the sheet in the range
  wins over `sheet`), a defined name (sheet scope preferred over workbook
  scope), or a table name.
- Without `range`, the data block is detected: leading empty rows and
  columns are skipped, the header is the first row with two or more non-empty
  cells, and the block extends to the last non-empty row and column. The
  `range` output reports the choice as `Sheet!A1:F120`, the sheet name
  unquoted even when it holds a space; the `range` of a read, validate, or
  convert covers the header row when there is one.
- `header` is `true` (default: the first row of the range names the
  columns), `false` (columns are named `A`, `B`, `C`), a row number, or a
  list of row numbers whose cells are joined with a space, so `Amount` merged
  over `Net` and `Tax` yields `Amount Net` and `Amount Tax`. Header text is
  trimmed, line breaks become spaces, an empty header becomes the column
  letter with the warning `Sheet!C1: header is empty; column named C`, and
  a duplicate gets a `_2` suffix with the warning `Sheet: duplicate header
  "Amount" renamed Amount_2`.
- `columns` keeps and orders columns: names, `Invoice No: invoice_no` to
  rename, or `{Invoice No: invoice_no}` entries. A name that matches no header
  fails the step with `columns: column "Nope" not found; headers present:
  ...`; the same form with the `types:` and `where:` prefixes reports a
  bad name in those fields.
- `merged` is `fill` (default: a merged value is read in every cell it
  covers) or `first` (only the top-left cell).

### Typing

| Cell | Value |
| --- | --- |
| Number | JSON number. Integral values within 2^53 are integers. |
| Number with a date, time, or date-time format | ISO 8601 text: `2026-10-01`, `15:04:05`, or `2026-10-01T14:30:00`. A serial with a fraction under a date-only format is a date-time. Built-in formats 14 to 22, 27 to 36, 45 to 47, and 50 to 58 count, and so does a custom format with date or time tokens outside quotes, such as `yyyy"年"m"月"d"日"`. `[h]:mm` counts elapsed time and stays a number. The 1900 and 1904 date systems are honored. |
| Text, including text-formatted numbers | String, so leading zeros survive. `trim: true` removes surrounding white space, including the full-width space U+3000. |
| Text under a pinned `number`, `integer`, `date`, `datetime`, or `boolean` | Japanese forms are read as the section below describes: full-width text, yen amounts, kanji numerals, kanji and era dates, times of day, and yes/no marks. The failure message quotes the text as the cell holds it. |
| Boolean | `true` or `false`. |
| Formula | Its cached value. `formulas: text` yields `=` and the formula; `formulas: calculate` evaluates it. A formula without a cached value is evaluated with the warning `Sheet!B2: formula had no cached value; evaluated`; one that cannot be evaluated is null with `Sheet!B3: formula NA() could not be evaluated: #N/A`. |
| Error such as `#N/A` | Null, with the warning `Sheet!B3: error cell #N/A`. |
| Empty | Null. Trailing rows whose cells are all null are dropped unless `keep_empty_rows: true`. |

Each row carries `_row`, its 1-based sheet row number. `types` pins columns
to `string`, `number`, `integer`, `boolean`, `date`, or `datetime`; a cell
that cannot convert fails the step naming the cell, or with
`on_type_error: warn` (also spelled `"null"`, quoted so YAML keeps the
word) becomes null with a warning. With `on_type_error: keep` it keeps the
value it was read as, with the same warning, for a reader that shows a
person what to fix.

#### Japanese text under a pinned type

A text cell under a pinned type is first folded: full-width digits,
letters, and punctuation read as their ASCII forms, the minus sign U+2212
as a hyphen, and surrounding white space, the ideographic space included,
is dropped. Then the forms below are read. A cell read without a pinned
type keeps its text, and so does a `write_cells` or `update_rows` literal.
`xlsx.read`, `xlsx.validate`, `xlsx.convert`, `xlsx.extract`, and the
writers' `types` all read this way; `where` and header matching compare
only the narrow forms, digits with a thousands separator, `¥`, and `円`, so
a filter never matches `10%` as 10 on an unpinned column. A cell none of
the forms fit fails the type with the text quoted as the cell holds it,
`expected number, found "約1,000"`.

Under `number` and `integer`:

| Form | Examples | Value |
| --- | --- | --- |
| Thousands separators in groups of three, `,` or `、` | `123,456`, `1,234,567.89`, `1、000` | 123456, 1234567.89, 1000 |
| Yen before or after, `JPY` either side | `¥1,500`, `¥123,000`, `JPY 1,000`, `123,000円`, `1,000 JPY` | 1500, 123000, 1000, 123000, 1000 |
| Receipt wrapper | `金1,000円`, `金壱万円也`, `1,000円也` | 1000, 10000, 1000 |
| Trailing dash after an amount | `¥123,000-`, `123,000円-`, `¥1,000ー` | 123000, 123000, 1000 |
| Negative sign, before or after the yen sign | `-5`, `▲1,000`, `△1,000`, `-¥1,000`, `¥-1,000`, `¥▲1,000`, `▲1,000円` | -5, -1000, … |
| Accounting parentheses | `(1,000)`, `(1,000)`, `(¥1,000)` | -1000 |
| Percent, the number before the sign | `10%`, `10%`, `-10%`, `10.5%` | 10, 10, -10, 10.5 |
| Price tag before, or in parentheses at either end | `税込1,000`, `合計金額:¥123,000`, `1,000(税込)`, `¥1,000-(税抜)` | 1000, 123000, 1000, 1000 |
| Kanji numerals, units and daiji | `千`, `百万`, `三千五百`, `一万二千`, `壱拾弐万参千`, `金壱拾弐萬参阡円也` | 1000, 1000000, 3500, 12000, 123000, 123000 |
| Kanji digits in place, with no unit | `二〇二六`, `〇`, `零` | 2026, 0, 0 |
| Arabic digits with units | `12万3,500円`, `1,200万`, `1.5億`, `12.5万円`, `1,200千円`, `3百万円`, `12万3千4` | 123500, 12000000, 150000000, 125000, 1200000, 3000000, 123004 |

The tags are `消費税込`, `消費税抜`, `消費税別`, `消費税`, `税込`, `税抜`,
`税別`, `内税`, `外税`, `本体価格`, `合計金額`, `合計`, `小計`, `総計`,
`総額`, `金額`, `単価`, and `概算`, bare with an optional colon, or in
parentheses; any other parenthetical, such as `1,000(千円)` or
`1,000(USD)`, is not read, since it changes the amount or the currency.
Units are `十` (`拾`), `百` (`佰`), `千` (`阡`, `仟`), `万` (`萬`), `億`, and
`兆`; a unit with nothing before it is one unit; a trailing bare digit is
the ones place, as in `二十六`; units must descend, so `3万2億` is not
read. `▼` is not a sign. Among the earlier forms, `1,2,3` is no longer
read: separators must group three digits.

Under `date` and `datetime`, after the ISO and slash forms the Typing table
lists:

| Form | Examples | Value |
| --- | --- | --- |
| Kanji date, digits Arabic or kanji | `2026年10月3日`, `2026年10月3日`, `二〇二六年十月三日`, `二千二十六年十月三日` | `2026-10-03` |
| Numeric with one separator throughout | `2026/10/3`, `2026.10.3`, `2026-10-3` | `2026-10-03` |
| Month only, the first of the month | `2026年10月`, `2026/10`, `2026.10`, `令和8年10月`, `R8.10` | `2026-10-01` |
| Era, long or short | `令和8年10月3日`, `令和元年5月1日`, `令和八年十月三日`, `平成三十一年四月三十日`, `㋿8年10月3日`, `R8.10.3`, `r8/10/3`, `H31-4-30`, `令6.4.1` | the Gregorian date |
| No year, the current year of the host | `10月3日`, `十月三日`, `10/3` | `2026-10-03` in 2026 |
| A weekday mark, dropped | `2026年10月3日(金)`, `令和8年10月3日(金曜日)`, `2026/10/3 (Fri)`, `2026年10月3日 金曜日` | `2026-10-03` |
| A time of day after any date | `2026/10/3 14:30`, `2026年10月3日14時`, `2026年10月3日 14時30分`, `令和8年10月3日 14時30分15秒` | `2026-10-03T14:30:00`, … |
| 午前 and 午後 | `2026年10月3日 午後2時30分`, `午前9時`, `午後0時`, `午後12時`, `午前0時`, `午前12時` | 14:30, 09:00, 12:00, 12:00, 00:00, 00:00 |

The eras are 明治 (M, 1868), 大正 (T, 1912), 昭和 (S, 1926), 平成 (H,
1989), and 令和 (R, 2019), named in full, by initial, by their first
kanji, or by the ligatures `㍾`, `㍽`, `㍼`, `㍻`, and `㋿`, each from the day
it began, so `令和元年4月30日`, before 令和 began on 2019-05-01, is not a
date. A day the month does not have, a 13th month, an hour past 23, or
`24:00` is refused; time digits are Arabic only; `10.3` is a number, not
a date; `2:30 PM` is not read. A weekday is dropped, not checked against
the date. Under `date` a time of day is dropped. A date with no year reads
differently in another year, which the extract cache does not notice.

Under `boolean`, after folding and lowercasing:

| Value | Text |
| --- | --- |
| `true` | `true`, `yes`, `y`, `on`, `ok`, `1`, `はい`, `有`, `有り`, `あり`, `済`, `済み`, `完了`, `完`, `了`, `可`, `要`, `必要`, `対象`, `該当`, `真`, `○`, `〇`, `◯`, `◎`, `●`, `✓`, `✔`, `✅`, `☑`, `レ` |
| `false` | `false`, `no`, `n`, `off`, `ng`, `0`, `いいえ`, `無`, `無し`, `なし`, `未`, `未済`, `未了`, `未完`, `未完了`, `不可`, `否`, `不要`, `対象外`, `非該当`, `偽`, `×`, `✕`, `✗`, `✘`, `❌`, `☐`, `-`, `ー`, `―`, `—` |

Nothing else is a boolean: `△`, `☒`, `未定`, `保留`, and `対応済み` fail
with the text quoted, since a prefix such as `未` or a suffix such as `済`
does not decide a word.

### Reading

`where` keeps rows: a scalar matches equal values, `""` matches empty
cells, `{ne: v}` excludes a value, `{in: [a, b]}` matches a list. Two
values that both read as numbers, the text `"7"` included, compare
numerically and everything else as trimmed text. Keys may use the
original header, a loose match, or a `columns` alias.

`stop_at_blank: true` stops at the first row whose resolved values are all
null. `max_rows` (default 5000) counts rows that hold a value; blank rows
between them are kept as null rows without counting, unless
`keep_empty_rows` is set, in which case every row counts. Reaching the cap
stops reading; `truncated` is set, with the warning `stopped after 5000
rows; set max_rows to read more`, only when more countable rows remain
below the cap, so a sheet holding exactly `max_rows` rows is not truncated.
Rows that exceed the step output budget, 900 KiB or `max_output_size` less
64 KiB (half of `max_output_size` when that is 64 KiB or less), are left
out from the end and `truncated` is true with the warning `output
truncated to N of M rows; narrow the range or columns, or filter with
where`.

Rows hidden by a filter or by hand are read like any other row.
`skip_hidden: true` leaves them out: they are not returned, not counted
toward `max_rows`, and do not stop a `stop_at_blank` read. A row past the
last row holding a cell counts as shown.

### Describing a workbook

`xlsx.info` publishes one entry per sheet in `sheets`: `name`, `hidden`
(true for a sheet hidden from the workbook's tabs, very hidden included;
absent otherwise), `used_range`, `range` (the detected table),
`header_row` (0 for an empty sheet), `headers`, `types`, `row_count`,
`hidden_rows`, `columns`, `profile_truncated`, and `tables` (`{name,
range}`). `hidden_rows` counts the data rows holding a value that are
hidden by a filter or by hand, among the rows the profile reads; it is
absent when there are none. `types` and `columns` cover the data rows
of the detected table that hold a value, up to 5000; when the table holds
more, `profile_truncated` is true, and is absent otherwise. A column's type
is the kind most of its cells hold, a column mixing integers and decimals
being `number` and one mixing dates and datetimes `datetime`; an empty
column is `string`.

`columns` profiles each column in header order:

| Field | Meaning |
| --- | --- |
| `name`, `type` | The header and its entry in `types`. |
| `filled`, `blank` | Cells that hold a value, and cells that are empty or hold only white space. |
| `distinct` | Distinct values, compared as trimmed text, counted up to 1000. |
| `values` | The distinct values in order of first appearance, when there are 12 or fewer and one repeats, so a column of unique identifiers lists none. A value longer than 40 characters is cut to 40 ending in `…`. |
| `min`, `max` | The lowest and highest number of an `integer` or `number` column, or date of a `date` or `datetime` column, over the cells that read as that type the way `types` reads them, so `12` counts as 12. |
| `odd` | Cells holding a value the column's type cannot read even when pinned with `types`, such as `未定` in a number column. `12`, `三千`, and `令和8年10月3日` read under their type and are not odd; a `string` column has none. |
| `odd_cells` | The first three odd cells as `{cell, text}`, such as `{"cell": "D300", "text": "未定"}`, the text cut like a value. |

`values`, `min`, `max`, `odd`, and `odd_cells` are absent when there is
nothing to report. A sheet reports its first 20 read warnings, such as
`Sheet1: Sheet1!B3: error cell #N/A`, then `Sheet1: 380 more warnings`.

### Writing

`xlsx.write` takes `rows` or `input`, not both. `rows` is a list of objects
or arrays, usually `${steps.<id>.outputs.rows}`; the key order of JSON text
is kept, while objects from YAML or a decoded map are written in sorted key
order unless `columns` orders them. `input` is a `.json` array, a `.jsonl`
file, or a `.csv` with a header line whose cells are text unless `types`
converts them; `format` overrides the extension. A `_row` field is never
written. A replace, or a write that creates the sheet, writes the table's
own order as the header; an append onto a sheet that already has rows
matches each field to the header row by name (see below), so a named row's
key order does not matter there.

Missing parent directories are created after validation for an actual workbook
write, not during a dry run. An input rejected before save does not create
directories. A missing workbook is created; a `sheet` that does not exist is
created; an
existing sheet is replaced (`mode: replace`, the default) or extended
(`mode: append`). A replaced sheet is cleared in place: its merged regions
and tables are removed, and every cell holding a value or formula is
emptied of its value, style, and hyperlink. Cells that carry only a style
are cleared as well when the sheet's stored dimension spans at most 2^20
cells; past that, a sweep of the whole rectangle is skipped so a large
sparse sheet stays cheap to replace, and such style-only cells keep their
style. A hyperlink on a cell the clear does not visit, one that is empty
and outside both the stored dimension and the cells holding values, is
kept for the same reason. The sheet itself, its position, the defined names scoped to it, and
formulas on other sheets that refer to it stay valid. Other sheets, column
widths, styles, and defined names are untouched. An AutoFilter on the
replaced sheet stays in place, as the underlying library offers no way to
remove one. A replace rebuilds the sheet; to fill a formatted sheet or a
form and keep its look, use `xlsx.write_cells`, `xlsx.update_rows`, or
`xlsx.append`, which keep each cell's style.

Values are written by type: numbers as numbers, booleans as booleans,
`2026-10-01` and `2026-10-01T14:30:00` strings as dates, other strings as
text. `types` pins a column: `number` and `date` convert strings, `string`
keeps ISO-looking text as text.

`style: table` (default) makes a new or replaced sheet look finished: bold
header on a light fill, frozen below the header, column widths fitted to
content between 8 and 60 characters with East Asian characters counting
double, and number formats by column kind: integers General, so a fraction
written later under the same format keeps its digits, decimals with two
places, dates `yyyy-mm-dd`, date-times `yyyy-mm-dd hh:mm:ss`, text `@`. A
column's kind is its pinned type, or else the kind most of its values have;
a whole number is an integer however it arrives, so JSON `17500` shows as
`17500`, and a column mixing integers and decimals is a decimal column. A
column mixing dates and date-times is formatted as date-time. `style: none`
writes bare cells. `formats` on `xlsx.write` and `xlsx.append` names a
number format per column, such as `{Total: "#,##0", Rate: "0.0%"}`: every
cell written to that column takes it under either style, in place of the
format of the column's kind or of the cell above; an appended cell keeps
the rest of the style it copies.

`xlsx.append`, and `mode: append`, write below the last non-empty row with
no header, and each new cell copies the style of the cell above it, so a date
column stays a date column. An append that starts an empty sheet writes the
header so the first run creates a table. A cell to append that lies in a
merged cell, such as an empty notes box below the rows, fails the step with
`Sheet1!A3: cannot append into merged cell A3:B4; unmerge it to write this
cell`, since every value written inside it would land in its top-left
cell. With `output`, `xlsx.append` writes the result there and leaves the
workbook at `path` as it was, by the rules `xlsx.write_cells` follows (see
Writing cells), with `leave output out to append to the workbook in place`
ending the message for an output that is the workbook itself; a `path`
that does not exist starts a new workbook at `output`. An existing `output`
is replaced, not appended to: `path` is read, the rows are appended in
memory, and the result overwrites `output`, so an `xlsx.append` with a fixed
`output` inside a `foreach` keeps only the last iteration's rows.

Fields are placed by name. Object rows, rows from a CSV header, and rows
given `columns` are matched to the sheet's header row, its first row, by
exact name: a name the header has only loosely fails with `column "amount"
not found in header row 1; did you mean "Amount"?`; a name the header lacks
adds a column at the right, its header cell copying the style of the last
header cell, counted in `columns_added`; header columns no field carries
stay empty. A new header cell inside a merged cell would land in the merged
cell's top-left cell, which may hold another column's header, so it fails
with `Sheet1!C1: merged cell B1:C1 covers the header cell of new column
"note"; unmerge it to add the column`. Duplicate header names read as `Amount` and `Amount_2`. Array
rows have no names and are written by position. `header: false` says the
sheet has no header row: rows are written by position, and an empty sheet
gets no header. A sheet with rows but no header row fails with `no header
row found; use header: false to append rows by position`.

### Updating rows

`xlsx.update_rows` takes `rows` (objects, each with the `key` column and
optionally `_row`), `key` (a column name, or `_row` to address rows by
number alone), and `set`:

```yaml
set:
  Status: status            # a field of each row
  Reviewed: {value: "yes"}  # one literal for every row
```

A literal that is text in the canonical form of a number, `100` or `-12.5`,
is written as a number, since a reference interpolated into `with` arrives
as text; `{value: "007", type: string}` pins text, and `type` takes any
column type. Without `set`, every field other than the key and `_row` goes
to the column of the same name. A field in `set` that no row carries is an
error:
`set.Status: field "state" is not in any row`. An empty `rows` list, such as
a scheduled run with nothing pending, succeeds and changes nothing, with or
without `set`. A column that is not in the header row is added at the right
when a row is written, its header cell copying the style of the last header
cell; a new header cell inside a merged cell fails as it does for an append.

Rows are matched by `_row` when present, else by the key column, with keys
compared as trimmed text so `7` and `"7"` match. A key found at two rows is
an error: `key "INV-1" appears at rows 5 and 9`. A key not found does what
`missing` says: `fail` (default) with `key "INV-99" not found`, `skip` with
the warning `Orders: key "INV-99" not found; row skipped`, or `append`
below the last used row, copying the styles of the row above. With
`key: _row`, `missing` must be `fail`.

`rows` may also be the aggregate output of a `foreach` step, an object with
`summary`, `items`, and `outputs`; its `outputs` list, the collected
objects of the item bodies, is used. Two input rows that address the same
sheet row are an error: `rows[0] and rows[2] both address row 17`.

Only the columns in `set` change. A row that does not carry a mapped field
leaves that cell as it is; an explicit null empties it. A cell whose value
already matches, compared with its type so the number 7 and the text `7`
differ and text exactly, surrounding space included, is not counted as
changed. A date written into a date column keeps
the column's format; a date written elsewhere gets a date format. The key
column cannot be in `set`: `set.Invoice No: the key column cannot be
updated`.

A cell inside a merged cell is the merged cell: it is compared, written,
and styled at its top-left cell, the one Excel shows. Rows that share a
merged cell, such as the lines of one order under a merged Status, may
write it the same value, which is written once and counted once in
`cells_changed`; different values fail the step with `rows[0] and rows[1]
write different values to merged cell Sheet1!C2:C3`, a row that leaves the
cell as it already is included.

With `output`, the updated workbook is written there and the workbook at
`path` is left as it was, so one workbook serves many updates; on Windows a
workbook another program holds still fails the step, as for
`xlsx.write_cells`. A missing parent folder of `output` is created as for
`xlsx.write`.
`output` must be a workbook and a different file from `path`, a hard link
included, else `orders.xlsx: output must be a different file from path;
leave output out to update the workbook in place`; the output path takes
the saved file's place in `path` and `artifact`. An existing output is
replaced.

### Shape checks

Three checks run before anything is saved, and any failure aborts the step
with the workbook untouched. They cannot be turned off.

1. The key column and every column in `set` that already exists must still be
   in the header row by exact name. A header that matches only
   ignoring case or spacing is reported with the name found.
2. Every row carrying `_row` must still hold its key at that row.
3. A merged cell to write, including one an appended row would land in,
   must lie within one column of the data rows; one reaching into another
   column such as the key, into the header, or below the data rows would
   carry the write there: `orders.xlsx Sheet1!C4: merged cell A4:C4
   reaches outside column "Status" of the data rows; unmerge it to write
   this cell`.

### Change summary

Every writer publishes `changes`:

```json
{"sheet": "Orders", "range": "Orders!A2:F148", "rows_updated": 147,
 "rows_appended": 0, "columns_added": 1, "cells_changed": 294}
```

`range` is the bounding box of the cells written, the header row included
when the writer wrote it. `dry_run: true` computes the same summary and
saves nothing.

### Atomic save and locks

A save writes a short-named temporary file beside the workbook, gives it the
target's permission bits, and renames it over the target, so a crash never
leaves a half-written workbook; a symbolic link is followed so the workbook
it points to is replaced. `atomic: false` saves in place. Excel's
`~$name.xlsx` lock file is checked before a write: a lock file another
process still holds open, which on Windows means Excel has the workbook,
fails the step; a lock file nobody holds is a leftover of a crash, so the
step warns and continues: on Windows, where the hold can be probed,
`~$orders.xlsx exists but no program holds it; the workbook may have been
closed without cleanup`, elsewhere `~$orders.xlsx exists; the workbook may
be open in another program, or the lock file may be a leftover`. On
Windows, a sharing violation on open or save fails the same way, and so
does a rename refused because another program holds the workbook open:
Excel shares only reads, so the temporary file is written but the rename
over the original is refused with access denied, and the step tells that
hold apart from a read-only workbook or a permission problem, which keep
the system error. A refused rename leaves no temporary file behind.
`wait_for_unlock: 5m` retries a locked workbook, waiting two seconds and
doubling to one minute, and logs each wait; the whole open-modify-save
sequence runs again on each try.

Writes to one workbook from the same Dagu process run one at a time, so
foreach items that update one workbook concurrently keep every change. A
write that finds another write of the same workbook in progress logs
`orders.xlsx is being written by another step; waiting for it to finish`
and starts when that write ends; the wait is not limited by
`wait_for_unlock`, and a step stopped while it waits leaves the workbook
untouched. Paths that reach one file through a symbolic link, or that
differ only in case on a filesystem that ignores case, are one workbook,
and a writer with `output` (`xlsx.write_cells`, `xlsx.update_rows`, or
`xlsx.append`) holds both the workbook at `path` and the output. Writes
from another process, such as another `dagu start` or a distributed
worker, are not coordinated: the last save wins.

### Artifacts

`artifact: true` on a writer copies the saved workbook under
`xlsx/<step>/` in the run's artifacts directory and publishes its relative
path as `artifact`. The option enables artifact storage for the DAG, as
does a value reference such as `${params.KEEP}` whose value is only known
at run time. A dry run copies nothing and publishes no `artifact`. A copy
that fails after the workbook
was saved is reported as a warning, not as a failed step, so a retry does
not repeat a write that already happened.

### CLI

`dagu xlsx inspect <path> [--rows N] [--sheet NAME] [--password P]
[--format text|json]`
prints what `xlsx.info` publishes plus up to N typed sample rows per sheet;
`--sheet` describes one sheet, matched as `sheet` matches, and a name that
is not in the workbook fails with `sheet "Nope" not found; sheets present:
...`. `dagu xlsx read <path>
[--sheet] [--range] [--header] [--columns] [--types] [--on-type-error]
[--max-rows] [--skip-hidden] [--password P] [--format]` prints what
`xlsx.read` publishes, the flags taking the values the fields take.
`--types` is comma-separated `name:type` pairs, split at each pair's last
colon, or a JSON object when a name holds a comma; `--on-type-error` is
`fail` (default), `warn`, or `keep`.
For both commands, a path of `-` reads the workbook from standard input. The
input is limited to 64 MiB; larger input fails before workbook parsing. The
JSON inspect result uses `-` as its `path`.
`--password` is the password of a protected workbook. When the flag is
omitted, `DAGU_XLSX_PASSWORD` supplies it; when both are set, the flag
wins, including when the flag is empty. The variable is the recommended
form, since a flag value is visible in the process list and kept in
shell history. A protected workbook opened
without the right password fails with `<name>: workbook password is
missing or incorrect`.
Both read the supplied path or standard input directly, create no run, and
exit non-zero with the error on stderr. The `text` format of `inspect` prints a
heading,
`orders.xlsx: 2 sheets, 1900 date system`, then one line per sheet,
`Sheet "Orders": used A1:C3, table Orders!A1:C3, header row 1, 2 rows`,
with `(hidden)` after the name of a hidden sheet, `, 3 hidden` after the
row count when rows are hidden, and `, first 5000 profiled` at the end
when the profile is truncated, followed
by the columns with their profiles and the sample rows. Each column reads
`Name (type: values; min..max; N blank; N odd: CELL "text", ...)`, each
part present only when the profile has it, as in `Columns: Invoice No
(string), 状態 (string: 済, 未; 40 blank), 数量 (number; 1..250; 1 odd:
D300 "未定")`. The `text` format of `read` prints a tab-separated header
line starting with `_row` and one line per row.

`dagu xlsx cache clear <DAG> [--step ID]` removes the cached cell
addresses of a DAG's `xlsx.extract` steps on this host, printing `Removed
xlsx replay cache for step "fields" of DAG "quotes"` for each step removed
or `No xlsx replay cache for DAG "quotes"`; `dagu rm --history` removes
them together with the run history. A `dagu xlsx extract` command that
runs one extraction outside a run needs a model configured outside a DAG
and is a later addition.

### MCP

The `dagu_read` target `workbook` takes `path`, any workbook the server
process can read, and an optional `password` that opens a protected
workbook. It returns the `inspect` description with five sample
rows per sheet. The path is recorded in the audit log as `workbook_path`;
the password is not. Spec 021 defines the target's fields and errors.

### Validating

`xlsx.validate` reads a sheet the way `xlsx.read` does (`sheet`, `range`,
`header`, `columns`, `merged`, `skip_hidden`, `trim`, `formulas`) and checks every
non-empty row against rules: `required` lists columns the header row must
have, `not_blank` columns no row may leave empty, `unique` columns whose
values may not repeat, `types` columns whose cells must convert, and
`allowed` the values a column's cells may hold. Names in the rules match a
header exactly, loosely (ignoring case and spacing), or through a
`columns` alias. `unique` and `allowed` skip empty cells; `not_blank` is
the rule for those. At least one rule is required.

Every failed check is one problem with a `code`, the `sheet`, the `cell`
and `row` (absent for a missing column), the `column` as the header reads,
and a `message`:

| Code | Problem |
| --- | --- |
| `missing_column` | A rule names a column the header row does not have; that rule is skipped. |
| `blank` | A `not_blank` column is empty: `Status is blank`. |
| `type` | A cell cannot convert to its column's type: `expected number, found "N/A"`. |
| `duplicate` | A `unique` value seen before: `duplicate value "INV-1"; first at row 5`. |
| `not_allowed` | A value outside the allowed list: `value "Pending" is not one of Done, Open`. |

`count` is every problem found, `rows` the non-empty rows checked, and `ok`
is true when `count` is zero. Problems are listed with the missing columns
first, then row by row, and within a row in rule order: `not_blank`,
`types`, `unique`, `allowed`. `problems` keeps at most `max_problems`
(default 1000) and is fitted to the output budget like `rows`; `truncated`
says when either cut it, and the budget cut adds the warning `problems
truncated to 2 of 4; narrow the range or set max_problems`. The stdout
line is `Validated 147 rows in orders.xlsx Orders!A1:F148: 3 problems`,
`1 problem`, or `no problems`. Each kept problem also goes to stderr as
`problem: Sheet!Cell: message`, or `problem: Sheet: message` for a missing
column, which has no cell.

With `on_problem: warn`, the default, the step succeeds with the problems
published. With `on_problem: fail` the step fails after listing them, with
`N problems found in <workbook> <sheet>`; a failed step publishes no
outputs (Spec 012), so a workflow that must act on the problems keeps the
default.

To stop and ask someone when a workbook has problems, follow the
validation with a `human.task` step whose precondition is the count, and
let the rest of the workflow continue past a skipped task:

```yaml
  - id: check
    action: xlsx.validate
    with:
      path: orders.xlsx
      required: [Invoice No, Amount]
      allowed: {Status: [Open, Done]}
  - id: review
    depends: check
    action: human.task
    preconditions:
      - condition: "${steps.check.outputs.count}"
        expected: "num:>0"
    continue_on:
      skipped: true
    with:
      prompt: Fix the problems in orders.xlsx and continue
```

With no problems the task is skipped and the run goes on; with problems
the run waits for the task to be completed (Spec 031) and continues from
there.

### Extracting fields from a form

`xlsx.extract` reads a sheet laid out as a form rather than a table: a
quote, an order, or an application whose layout differs by sender.
`instruction` says what to find, and `schema` (`type: object`) names the
fields as properties. A model is asked which cell holds each field; the
step then reads the typed value from that cell. The model never returns a
value, so every non-null output has a cell behind it and no number is
invented.

The model is sent the sheet's non-empty cells in its used range, or in
`range`, one per line as `B3 [text,bold]: 見積番号`: the address, the kind
the cell's type and number format give (`text`, `number`, `date`,
`datetime`, `time`, `bool`), `bold` and `fill` when the cell has them, and
the text with line breaks as spaces, cut to 200 characters; a merged
region is listed once as `A1:D1 [text,bold]: 御見積書`. `send_values:
false` lists a non-text cell as its kind only, `B7 [number]`, so amounts
and dates stay on the host. More than 2000 listed cells fails with
`quote.xlsx Sheet1: 2415 cells in Sheet1!A1:H600 is more than 2000; set
range to the part of the sheet that holds the fields`. A listing longer
than 200 KB fails the same way before any request, so a sheet that could
not fit a model context is refused on the host: `quote.xlsx Sheet1: the
listing of Sheet1!A1:H600 is 412 KB, more than 200 KB; set range to the
part of the sheet that holds the fields`. Nothing else is sent: no
variables and no screenshots. An `instruction` or a property
`description` that holds the value of a declared secret of four or more
characters fails before any model call, and secret values are masked in
the text sent.

The model answers through one `respond` tool whose parameters list each
property as an A1 address, with or without a sheet, or `null` for a field
the sheet lacks, and `labels`, the addresses of the cells that are labels
or headings rather than values, which the cache watches; a property named
`labels` is refused at validation. A reply without a `respond` call is
accepted when its text is valid JSON. Any other reply without usable
`respond` arguments gets one correction: the next request to the same model
carries the rejected reply as an assistant message, followed by a user
message stating why it was rejected, and the step log shows the rejected
reply after `warning: <provider>/<model> answer rejected: <reason>`. An
answer that is not an object, that names something other than one cell
within the listed sheet and range, or whose `labels` is not a list of
addresses, is unusable, as is a corrected reply that is still unusable: the
next configured model is asked, and when every model's answer is unusable
the step fails with `model request failed: <provider>/<model>: ...` naming
each answer's fault. An empty cell within the range is a null value, since
a form may leave a box blank. `trim` and `formulas` read the cell as `xlsx.read`
would. A property's `type` pins the cell: `number`, `integer`, `boolean`,
`string`, and `string` with `format: date` or `date-time`; a property
without a type gets the cell's own typed value. A cell that fails its type
fails the step with the usual `quote.xlsx Sheet1!B7: expected number,
found "n/a"`; Japanese text such as `¥123,000`, `12万3,500円`, `令和8年10月15日
午後2時30分`, or `済` is read as Japanese text under a pinned type describes. An empty cell or an absent field is null.

The step takes the DAG-level `llm` block, or `with.llm`, which replaces it,
as browser steps do. Several models are tried in order, each set up when
its turn comes, and an unusable answer, after its correction when it gets
one, or a model that cannot be set up counts as a failed request. The
outputs are each property, `cells` (property to the `Sheet1!B7` it was
read from, empty when absent), `sheet`, `warnings`, and `source`, `model`
or `cache`.
The stdout line is `Extracted 4 fields from quote.xlsx Sheet1 (model, 1234
tokens)` or `Extracted 4 fields from quote.xlsx Sheet1 (cache)`; the token
count covers every model call, a rejected reply's included.

With `cache: true`, the default, the answered addresses are kept by sheet
layout under `<data dir>/xlsx/cache/<dag>/<step>.json`. The layout is the
sheet's shape: which cells hold something, their kinds and emphasis, and
the merged regions, so two forms from one sender in the same template
share it while their values differ. The entry also keeps a digest of the
text of every label the model named, and of the label beside each
answered cell, the nearest text cell to its left or above, and the
addresses of the cells that were listed. A later run whose layout,
instruction, and schema (the property names, types, formats, and
descriptions) match, and whose labels all still read the same, reads the
cached cells without a model call (`source: cache`). A run of another
layout is served by an entry of the same instruction and schema whose
labels all hold and whose listed cells include every cell of this run,
so a form of the same template with a box left blank is read from the
cache, with the blank field null, while a form that lists a cell no
entry has seen asks the model, so a blank box recorded earlier never
hides a value. A layout no entry serves asks the model; an entry whose
instruction or schema changed, whose label moved or was renamed, or
whose address no longer names one cell is replaced after the model
answers. A label renamed in place is noticed wherever the model named a
label, including the label of a field the sheet lacked; a text cell the
model did not name is not watched. An entry is kept only when the step
succeeds; a failed step changes nothing. `cache: false` asks the model
every run.

`dagu dry` checks that the workbook and the sheet exist, nothing about the
model.

### Writing cells

`xlsx.write_cells` fills named cells of an existing workbook, the way a
template is filled. `cells` maps addresses to what they receive: a cell
such as `B2`, `Sheet1!B2`, or `'My Sheet'!B2`, or a defined name that
refers to one cell; an address without a sheet uses `sheet`, the first
sheet by default. A range, a named range, or a table is refused with
`"A1:B2" is not a single cell`, and two addresses that name the same cell,
such as `B3` and `$B$3`, with `"B3" and "$B$3" name the same cell Sheet1!B3`.
A merged cell is one cell: an address inside it, or a range or defined name
covering exactly its area, as Excel names a merged cell, writes its
top-left cell, and two addresses inside one merged cell fail with `"B10"
and "C10" name the same merged cell Sheet1!B10:D10`.
`merge` lists ranges to merge before the cells are written, such as
`[A1:D1, B10:D10]`, so a filled template can carry a title across its
width; an address inside a merged range then writes its top-left cell.
A range that is already merged exactly is left as it is. A range that
overlaps another merged region fails with `template.xlsx: merge A1:D1
overlaps merged cell Sheet1!B1:E1`, and one covering a cell other than
its top-left that holds a value or formula fails with `template.xlsx:
merge B10:D10 would discard the value of Sheet1!C10; clear it first`,
since Excel keeps only the top-left value of a merged cell; clear the
cell in an earlier step.
A value is written as a cell value, with an
ISO date or date-time string becoming a date and text in the canonical
form of a number becoming a number: an optional leading `-`, digits with
no leading zero, an optional fraction, so `100` and `-12.5` are numbers
while `007`, `1,234`, `1e3`, and padded or full-width digits stay text.
This is what lets a reference such as `${foreach.item.amount}`, which
arrives as text, land as a number. `{value: v, type: t}` pins the type, so
`{value: "007", type: string}` and `{value: "100", type: string}` stay
text; `{formula: text}`
writes a formula, with or without a leading `=`, replacing the value the
cell held, so a read of the cell evaluates the formula; `null` empties the
cell and removes its formula. Every cell keeps its style, and a date written
into a cell with no date format gains one. A cell that already holds the
value, or the formula, is not a change; text and a date that read the
same, such as the text `2026-10-01` and that date, are told apart by what
the cell stores, so a date written over text is a change.

The workbook must exist: a template fill needs a template, and `xlsx.write`
creates workbooks. With `output`, the result is written there and the
workbook at `path` is left as it was, so one template serves many fills;
a missing parent folder of `output` is created as for `xlsx.write`;
`output` must be a different file from `path`, a hard link included, else
`output must be a different file from path; leave output out to fill the
workbook in place`; the output path takes the saved file's place in `path`
and `artifact`.
`changes` reports `cells_changed`, `rows_updated` as the distinct rows a
changed cell was on, `sheet` and `range` as the default sheet and the
bounding box of the cells changed on it, and `merged`, the ranges newly
merged, when there are any; the stdout line then ends with `(2 ranges
merged)`. `dry_run`, `atomic`,
`wait_for_unlock`, and `artifact` apply as for `xlsx.write`.

### Sheets

`xlsx.sheet` runs one `operation` on a workbook that must exist: `add`
creates the sheet named `sheet`; `copy` duplicates `sheet` as `to`;
`rename` gives `sheet` the name `to`; `delete` removes `sheet`. `position`
places a sheet `add` or `copy` creates, 1-based; by default an added sheet
goes last and a copy right after its source.

A rerun must be safe, so the modes say what happens when the workbook is
not as expected. `if_exists` applies to the sheet `add`, `copy`, and
`rename` would create: `fail` (default) with `sheet "October" already
exists`, `skip` with the warning `sheet "October" already exists; nothing
added` (or `copied`, `renamed`) and nothing changed, or `replace`, which
empties an added sheet in place, copies over the existing sheet keeping its
position, or drops the sheet in a rename's way. `missing` applies to the
sheet `copy`, `rename`, and `delete` start from: `fail` (default) or `skip`
with the warning `sheet "Template" not found; nothing copied` (or
`renamed`, `deleted`). Deleting the only sheet fails with `cannot delete
the only sheet "Sheet1"`. A rename to the name the sheet already has is a
skip with the warning `sheet "October" already has that name; nothing
renamed`; a rename that only changes case is applied.

The outputs are those of a writer plus `sheets`, the sheet names
afterwards; `sheet` is the sheet the operation produced or removed. The
underlying library sets three limits, stated here so a workflow can plan
for them: a rename does not rewrite formulas on other sheets that name the
sheet, though defined names follow it; a delete leaves such references
dangling and drops the names scoped to the sheet; and a copy carries
cells, styles, widths, merged regions, and validations but not tables,
images, charts, or page setup.

### Converting

`xlsx.convert` writes the rows of a sheet to the file named by `output`,
as `csv`, `json`, or `jsonl` by the file's extension (`.ndjson` counts as
jsonl) or by `format`. It reads the way `xlsx.read` does (`sheet`,
`range`, `header`, `columns`, `types`, `trim`, `merged`, `formulas`,
`skip_hidden`) but
with every row and no output budget, since the rows go to a file, and a
cell that fails a pinned type fails the step. Columns follow the header
order, or `columns`, and the `_row` field is not written: the file is a
table. CSV cells are text as a reader would show them, with dates in ISO
form and booleans as `true` and `false`; JSON keeps the typed values. The
file is written through a temporary file in its directory and renamed into
place unless `atomic: false`.

CSV takes `encoding`: `utf-8` (default, no byte order mark), `utf-8-bom`
for Excel to open the file as UTF-8 by double click, or `shift_jis` as
Windows uses it (code page 932, Windows-31J; `cp932`, `windows-31j`,
`sjis`, and `ms932` are accepted spellings), and `delimiter`, one
character, a comma by default. The outputs are `path`, `format`, `count`,
`sheet`, `range`, `warnings`, and with `artifact: true` the file's copy.
The other direction, a csv, json, or jsonl file into a workbook, is
`xlsx.write` with `input`.

### Input encoding

The `input` file of `xlsx.write` and `xlsx.append` takes the same
`encoding` and `delimiter` when it is csv; a byte order mark is dropped
either way. Both fields require `input`.

### Dry-run checks

`dagu dry` (Spec 064) runs a check for every xlsx step and warns, without
failing, when the step would fail on this host: the workbook of a reading
or updating operation, of `write_cells`, or of a `sheet` operation other
than `add` does not exist; the `sheet` named is not in it; a column named
in `columns`, `types`, `where`, or a validation rule is not in the header
row, where a `where` key or a rule name may be an alias given in `columns`
while the `types` of a read or convert name headers, as the run requires;
the key or a `set` column of `update_rows` is not in the header row
as written; or the `input` file of `write` or `append` does not exist. The
warning names the field, as `field 'with.sheet': orders.xlsx: sheet "Nope"
not found; sheets present: Orders`, and lists every problem found. A field
whose value still holds a reference to a step output is skipped, since no
step has run; params and environment resolve. A workbook the check cannot
open, such as one another program holds, is left to the run.

## Errors

### Validation

Every one of these is rejected by `dagu validate`:

- Any xlsx action without `with.path`: `path is required for <operation>`.
- `xlsx.write` or `xlsx.append` without `rows` or `input`:
  `<operation> requires with.rows or with.input`; with both:
  `<operation> accepts with.rows or with.input, not both`.
- `xlsx.update_rows` without `key`: `key is required for update_rows`;
  without `rows`: `update_rows requires with.rows`; with `header: false`:
  `update_rows needs a header row; header: false is not supported`.
- An `output` of `xlsx.update_rows` or `xlsx.append` that is not a
  workbook: `output: out.csv: only .xlsx and .xlsm workbooks are
  supported; save as .xlsx`. `xlsx.write` takes no `output`, also with
  `mode: append`: `with.output is not valid for xlsx.write`; use
  `xlsx.append` to append to a copy.
- A field of another operation, such as `range` on `xlsx.info`:
  `with.range is not valid for xlsx.info`.
- An unknown field: `unexpected additional properties ["strip"]`. A value
  outside its enum is reported by the schema as `<value> does not equal any
  of: [<the accepted values>]`, so `merged: join` gives `join does not equal
  any of: [fill first]`; the enums are `merged` (`fill`, `first`),
  `formulas` (`cached`, `text`, `calculate`), `on_type_error` (`fail`,
  `warn`, `null`, `keep`), `mode` (`replace`, `append`), `style` (`table`, `none`),
  `format` (`json`, `jsonl`, `csv`), `missing` (`fail`, `skip`, `append`),
  `on_problem` (`warn`, `fail`), `operation` (`add`, `copy`, `rename`,
  `delete`), `if_exists` (`fail`, `skip`, `replace`), `encoding` (`utf-8`,
  `utf-8-bom`, `shift_jis`, `cp932`, `windows-31j`, `sjis`, `ms932`), and a
  type in `types` (`string`, `number`, `integer`, `boolean`, `date`,
  `datetime`).
- `output`, `outputs`, or `stdout.outputs` on an xlsx step:
  `xlsx actions have fixed outputs`.
- `header` of a reader that is not `true`, `false`, a row number, or a
  list of row numbers: `header must be true, false, a row number, or a list
  of row numbers`; `header` of `xlsx.write` or `xlsx.append` that is a row
  number: `header must be true or false for <operation>`; `max_rows` below 1:
  `max_rows must be >= 1`; a `where` operator other than `eq`, `ne`, or
  `in`: `where: Status: unknown operator "like"; use eq, ne, or in`; `set`
  values that are neither a field name nor `{value: literal}`:
  `set.Status: use a field name or {value: literal}`; a `set` literal whose
  `type` is not a column type: `set.Status: type: unknown column type
  "money": use string, number, integer, boolean, date, or datetime`;
  `missing: skip` or
  `append` with `key: _row`: `missing: skip needs a key column; with key:
  _row nothing else identifies a row`; `wait_for_unlock` that is not a
  duration: `wait_for_unlock must be a duration such as 30s or 5m`; a blank
  entry in `formats`: `formats.Total must not be empty`.
- `xlsx.validate` with no rule: `validate requires at least one of
  with.required, with.not_blank, with.unique, with.types, or with.allowed`;
  `max_problems` below 1: `max_problems must be >= 1`; an `allowed` value
  that is not a list: `allowed.Status must be a list of values`.
- `xlsx.write_cells` without `cells`: `write_cells requires with.cells`;
  empty `cells`: `cells must not be empty`; a cell value of another shape:
  `cells.B2: use a scalar, null, {value: v, type: t}, or {formula: text}`;
  an empty formula: `cells.B2: formula must not be empty`; an `output`
  that is not a workbook: `output: out.csv: only .xlsx and .xlsm workbooks
  are supported; save as .xlsx`; a `merge` item that is not a range of
  cells with both corners, such as a single cell, an open end, or a
  defined name: `merge: "A1" is not a range`; an empty `merge`: `merge must
  not be empty`.
- `xlsx.sheet` without `operation` or `sheet`: `operation is required for
  sheet`, `sheet is required for sheet`; `copy` or `rename` without `to`:
  `to is required for copy`; `to`, `if_exists`, `missing`, or `position`
  on an operation that does not take it: `to is only valid for copy and
  rename`, `if_exists is only valid for add, copy, and rename`, `missing is
  only valid for copy, rename, and delete`, `position is only valid for add
  and copy`; `position` below 1: `position must be >= 1`.
- `xlsx.convert` without `output`: `convert requires with.output`; an
  output whose extension names no format and no `format`: `output
  extension ".txt" is not json, jsonl, or csv; set format`; `encoding` or
  `delimiter` with a format other than csv: `encoding applies to csv only`,
  `delimiter applies to csv only`.
- A `delimiter` that is not one character: `delimiter must be a single
  character`; `encoding` or `delimiter` on `xlsx.write` or `xlsx.append`
  without `input`: `encoding requires with.input`, `delimiter requires
  with.input`.
- `xlsx.extract` without `instruction`: `extract requires
  with.instruction`; without `schema`: `extract requires with.schema`; a
  schema that is not an object schema: `schema must have type: object`;
  `properties` that is not an object: `schema.properties must be an
  object`; a property that is not an object: `schema.properties.items must
  be an object`; a property of another type: `schema.properties.items:
  type must be string, number, integer, or boolean`; a property named like
  a fixed output:
  `schema property "sheet" collides with an output of xlsx.extract`;
  a property named `labels`: `schema property "labels" is reserved by
  xlsx.extract`;
  without a model: `xlsx.extract needs a model: set llm at the DAG level
  or with.llm on the step`; `llm` on another xlsx action: `with.llm is not
  valid for xlsx.read`.

### Runtime

- A path that is not `.xlsx` or `.xlsm`: an error containing
  `only .xlsx and .xlsm workbooks are supported; save as .xlsx`.
- A missing workbook: `<name>: workbook not found`.
- A protected workbook opened with no password or the wrong one:
  `<name>: workbook password is missing or incorrect`.
- A protected workbook whose encryption is not agile or standard:
  `<name>: workbook encryption is not supported`.
- A missing sheet: `sheet "Order" not found; sheets present: Orders, Summary`.
- A range that is none of the accepted forms:
  `range "Totals" is not a cell range, named range, or table`.
- A `columns`, `types`, or `where` name that matches no header:
  `columns: column "Nope" not found; headers present: ...`, with the field
  as the prefix.
- A cell that fails a pinned type:
  `orders.xlsx Orders!D17: expected number, found "N/A"`.
- A key column missing from the header row:
  `key column "Invoice" not found in header row 1; headers present: ...`, or
  with a loose match, `did you mean "Invoice No"?`.
- A `set` column of `update_rows`, or an appended field, with only a loose
  match: `column "status" not found in header row 1; did you mean
  "Status"?`.
- An append onto a sheet with rows but no header row: `no header row found;
  use header: false to append rows by position`.
- A `set` field no row carries: `set.Status: field "state" is not in any
  row`; the key column in `set`: `set.Invoice No: the key column cannot be
  updated`.
- A row that moved: `orders.xlsx Orders!A17: expected key "INV-17", found
  "INV-18"; the sheet changed since it was read`.
- A key not in the sheet with `missing: fail`: `key "INV-99" not found`.
- A key at two rows: `key "INV-1" appears at rows 5 and 9`.
- Two input rows addressing one sheet row: `rows[0] and rows[2] both
  address row 17`.
- Two input rows writing different values to one merged cell: `rows[0] and
  rows[1] write different values to merged cell Sheet1!C2:C3`.
- A merged cell to update reaching outside its column's data rows:
  `orders.xlsx Sheet1!C4: merged cell A4:C4 reaches outside column "Status"
  of the data rows; unmerge it to write this cell`; a cell to append inside
  a merged cell: `Sheet1!A3: cannot append into merged cell A3:B4; unmerge
  it to write this cell`; a new column whose header cell is inside a merged
  cell: `Sheet1!C1: merged cell B1:C1 covers the header cell of new column
  "note"; unmerge it to add the column`.
- An `output` of `xlsx.update_rows` or `xlsx.append` that is the workbook
  itself: `orders.xlsx: output must be a different file from path; leave
  output out to update the workbook in place`, or `to append to the
  workbook in place`.
- A workbook another program holds, on Windows:
  `orders.xlsx is open in another program; close it and retry`.
- `artifact: true` in a DAG whose artifacts are disabled:
  `artifact requires artifact storage`.
- `xlsx.validate` with `on_problem: fail` and any problem:
  `2 problems found in orders.xlsx Orders`.
- A `write_cells` address that is not one cell: `template.xlsx: "A1:B2" is
  not a single cell`; two addresses naming one cell: `template.xlsx: "B3"
  and "$B$3" name the same cell Sheet1!B3`, or one merged cell: `"B10" and
  "C10" name the same merged cell Sheet1!B10:D10`; a `merge` over another
  merged region: `template.xlsx: merge A1:D1 overlaps merged cell
  Sheet1!B1:E1`; a `merge` over a filled cell: `template.xlsx: merge
  B10:D10 would discard the value of Sheet1!C10; clear it first`; a
  workbook to fill that does
  not exist: `template.xlsx: workbook not found`; an `output` that is the
  workbook itself: `template.xlsx: output must be a different file from
  path; leave output out to fill the workbook in place`.
- A sheet to create that exists, with `if_exists: fail`:
  `report.xlsx: sheet "October" already exists`; a copy onto its own
  source: `sheet "Template" cannot be copied onto itself`; a `position`
  past the end: `position 5 is outside 1 to 4`, the upper bound being the
  sheet count plus one; deleting the last sheet: `report.xlsx: cannot
  delete the only sheet "Sheet1"`; a name Excel refuses: `invalid sheet
  name "Bad:Name"`; a source sheet that is not there with `missing: fail`:
  `sheet "Nope" not found; sheets present: Template`.
- A `convert` output that cannot be written, such as into a directory that
  does not exist: `out.csv: <system error>`.
- An `xlsx.extract` instruction holding a secret: `xlsx: with.instruction
  contains the value of secret SHOP_TOKEN, which would be sent to the
  model`, or a property description:
  `xlsx: with.schema.properties.total.description contains the value of
  secret SHOP_TOKEN, which would be sent to the model`; a sheet past the
  cell cap: `quote.xlsx Sheet1: 2415 cells in
  Sheet1!A1:H600 is more than 2000; set range to the part of the sheet that
  holds the fields`; a listing past the size cap: `quote.xlsx Sheet1:
  the listing of Sheet1!A1:H600 is 412 KB, more than 200 KB; set range to
  the part of the sheet that holds the fields`; a used range too large to
  scan, past a million cells:
  `quote.xlsx Sheet1: Sheet1!A1:XFD1048576 spans more than 1000000 cells;
  set range to the part of the sheet that holds the fields`; every model's
  answer unusable or every request failed: `model request failed:
  <provider>/<model>: <fault>` for each model, the fault being `xlsx: model
  answered field "total" with "123000", not a cell address`, `xlsx: model
  answered field "total" with "H40", which is outside Sheet1!A1:D20`,
  `xlsx: model answer is not an object: null`, or the provider's error.

Errors that concern a cell name the workbook, sheet, and cell as
`<workbook> <sheet>!<cell>: <message>`.

## Related Specs

- Step outputs and the output budget: [Spec 012](012-step-outputs.md)
- Built-in run context and the artifacts directory: [Spec 017](017-built-in-run-context.md)
- MCP read tool and the `workbook` target: [Spec 021](021-mcp-read-tool.md)
- Artifacts: [Spec 051](051-artifact.md)
- File actions, whose path handling the xlsx actions share: [Spec 052](052-file.md)
- Mailbox actions, whose output budget and attachment storage the xlsx actions mirror: [Spec 073](073-mailbox-actions.md)
- Preconditions, which gate the human task after a validation: [Spec 023](023-preconditions.md)
- Human tasks, which a validation hands its problems to: [Spec 031](031-human-task.md)
- Dry-run step checks, which the xlsx actions register with: [Spec 064](064-dry-run-step-checks.md)

## Examples

Read rows still to do, act on each, and write the result back:

```yaml
steps:
  - id: read
    action: xlsx.read
    with:
      path: ~/Inbox/orders.xlsx
      sheet: Orders
      where: {Status: ""}
  - id: each
    depends: read
    foreach:
      items: ${steps.read.outputs.rows}
      key: ${foreach.item.order_id}
      steps:
        - id: submit
          action: http.request
          with:
            method: POST
            url: https://erp.example.com/orders
            body: ${foreach.item}
            format: json
      collect:
        order_id: ${foreach.item.order_id}
        status: ${steps.submit.outputs.status_code}
    output: RESULTS
  - id: mark
    depends: each
    action: xlsx.update_rows
    with:
      path: ~/Inbox/orders.xlsx
      sheet: Orders
      key: order_id
      rows: ${RESULTS}
      set:
        Status: status
      wait_for_unlock: 5m
```

`collect` gives each item one object with the key and the result fields,
and `rows` takes the foreach aggregate, published as the variable
`${RESULTS}` (Spec 012), directly, using its `outputs` list. `http.request`
fails on a response outside 2xx, so a rejected order fails its item body;
the loop is then partially succeeded (Spec 018), the aggregate lists the
failed items under `items` and holds only the successful bodies in
`outputs`, and the write-back marks the rows that succeeded. The rejected
rows keep an empty Status, so the next run submits only them; the run ends
partially succeeded, which the run history shows and `dagu status`
reports.

Build a report from a query and keep it with the run:

```yaml
steps:
  - id: totals
    action: postgres.query
    with:
      dsn: ${env.REPORTING_DSN}
      query: select customer, sum(amount) as total from orders group by customer
  - id: report
    depends: totals
    action: xlsx.write
    with:
      path: reports/${context.attempt.started_at}.xlsx
      sheet: Totals
      rows: ${steps.totals.outputs.rows}
      columns: [customer, total]
      types: {total: number}
      artifact: true
```

Preview what an append would change, then do it:

```yaml
steps:
  - id: preview
    action: xlsx.append
    with:
      path: log.xlsx
      rows: ${params.ROWS}
      dry_run: true
  - id: append
    depends: preview
    action: xlsx.append
    with:
      path: log.xlsx
      rows: ${params.ROWS}
```

Fill an invoice template for each order and keep the copies with the run:

```yaml
steps:
  - id: orders
    action: xlsx.read
    with:
      path: orders.xlsx
      columns: [{Invoice No: invoice}, {Customer: customer}, {Amount: amount}]
  - id: each
    depends: orders
    foreach:
      items: ${steps.orders.outputs.rows}
      steps:
        - id: fill
          action: xlsx.write_cells
          with:
            path: templates/invoice.xlsx
            output: out/invoice-${foreach.item.invoice}.xlsx
            cells:
              Customer: ${foreach.item.customer}
              B7: ${foreach.item.amount}
              B9: {formula: "=B7*1.1"}
            artifact: true
```

Start a new month from the template sheet, safe to rerun:

```yaml
steps:
  - id: query
    action: postgres.query
    with:
      dsn: ${env.REPORTING_DSN}
      query: select item, amount from expenses where month = '${params.MONTH}'
  - id: month
    action: xlsx.sheet
    with:
      path: report.xlsx
      operation: copy
      sheet: Template
      to: ${params.MONTH}
      if_exists: skip
  - id: fill
    depends: [query, month]
    action: xlsx.write
    with:
      path: report.xlsx
      sheet: ${params.MONTH}
      mode: append
      rows: ${steps.query.outputs.rows}
```

Export a sheet for a system that reads Shift_JIS CSV:

```yaml
steps:
  - id: export
    action: xlsx.convert
    with:
      path: orders.xlsx
      sheet: Orders
      output: out/orders.csv
      encoding: shift_jis
      columns: [品名, 数量, 金額]
```

Read a supplier's quote, whatever its layout, into a list of quotes:

```yaml
llm:
  provider: anthropic
  model: claude-sonnet-5
  api_key_name: ANTHROPIC_API_KEY
params:
  - FILE: quote.xlsx
steps:
  - id: fields
    action: xlsx.extract
    with:
      path: inbox/${params.FILE}
      instruction: A supplier's quote. Find the quote number, the delivery date, and the total amount.
      schema:
        type: object
        properties:
          quote_no: {type: string, description: 見積番号}
          delivery: {type: string, format: date, description: 納期}
          total: {type: number, description: 合計金額}
  - id: record
    depends: fields
    action: xlsx.append
    with:
      path: quotes.xlsx
      sheet: Quotes
      rows: '[{"quote_no": "${steps.fields.outputs.quote_no}", "delivery": "${steps.fields.outputs.delivery}", "total": ${steps.fields.outputs.total}, "file": "${params.FILE}"}]'
```

A second quote from the same supplier shares the layout, so its fields are
read from the cached cells without a model call, and the typed values go
into the list the same way.