### Zoho Sheet — every call is a POST carrying `method=`, ranges are 1-based indexes, and errors hide until `--verbose`

This service does not look like the rest of zone. One verb (POST), one body style (`--form`), and the thing that selects the operation is a form field called `method` that **zone does not fill in for you**. Four things cause most failures: the missing `method`, reads being POSTs (so read-only guards block them), row/column **indexes** where you expect A1 notation, and a `records` API that stops at the first blank row.

#### The typed command name is not the method — you pass both

```bash
zone sheet workbook list --form method=workbook.list --form count=50
```

Dropping `--form method=…` is the commonest failure, and it surfaces as an opaque `Zoho Sheet HTTP 400`. Every command's `--help` prints its exact method string and fields. `--form` is the only body flag; there is no `--data`. `zone sheet resource exec <resourceId> --form method=<anything>` is the escape hatch.

**Run with `--verbose`.** Without it every failure is that same bare line; with it you get Zoho's specific `error_code` and `error_message` (here, `2831 — The parameter [method] … is missing`). Treat it as mandatory on this service, not a debugging step.

#### Addressing: one opaque workbook id, worksheets by name

`zone sheet workbook list` is the discovery call. Live, each row carries `resource_id`, `workbook_name`, `workbook_url`, `created_time`, `permission`; the envelope adds `resource_start_index`, `resource_end_index`, `resource_count`, `status`. `resource_id` is a 37-char opaque string, also the last segment of `workbook_url` — a user pasting a Sheet link is handing you the id. Timestamps come back as **human prose** (`"Tuesday, September 1, 2026 1:48:38 PM"`), not ISO or epoch. There is no `zone ctx sheet`: the id is a positional arg on most commands, and a `resource_id` **form field** on the fixed-path ones (`copy`, `share`, `publish`, `trash`, `restore`, `delete`, `insertimages`) and the whole `premium` group.

Worksheets go by `worksheet_name`; `worksheet_id` works wherever a name does, but it is a creation-order handle, not tab order — a live workbook whose tabs read **Q2, Q3, Q4** returns ids `0#`, `2#`, `1#`. Never infer a sheet from its id; read `zone sheet worksheet list`.

#### Ranges: 1-based row/column integers, not A1

`content`, `format` and `structure` take `start_row`/`start_column`/`end_row`/`end_column` as **1-based integers** — A1 is row 1, column 1. Only `namedrange`, `picklist` and `datavalidation` take A1 strings (`range=A1:B2`, `ranges=…`). `utility rangetoindex` and `utility indextorange` convert either way; `rangetoindex --form range_address=AA1:AB2` returns `start_column 27 … end_column 28`. That field needs a full `Start:End` pair — a bare `B2` is rejected (2876); write `B2:B2`, and never `Sheet1!A1:C3`.

Find the extent with `content usedarea` (→ `used_row_index`, `used_column_index`), then read inside it:

```bash
zone sheet content getrange <resourceId> --form method=range.content.get \
  --form worksheet_name=Sheet1 --form start_row=1 --form start_column=1 --form end_row=3 --form end_column=4
```

**The range response is sparse.** Empty cells and whole empty rows are absent — asking rows 1–3 returned only rows 2 and 3, and row 3 carried three of the four requested columns. Read `row_index` / `column_index` off each entry; iterating positionally silently shifts your data.

Cell reads also **stringify everything** — `getcell`/`getrange` return `"12"` where the records API returns `12`. Formulas compute server-side and reads give the **value, not the expression**: a cell verified to hold `=D7*35` returns `"210"` through both, and the formula text is recoverable only by exporting the workbook. `content recalculate` forces a recompute.

#### `records` vs raw cells vs `table`

`records` addresses rows by **column name** off a header row in a plain worksheet; `content` / `structure` address any cell by row/column index; `table` is the same idea against a named table object made by `table create`, but names its filter `criteria_json`. `premium` is `records` again at fixed paths, `resource_id` in the body, **no `method` field**.

`records` is the default for tabular data — objects keyed by header, plus the absolute `row_index`:

```bash
zone sheet records fetch <resourceId> --form method=worksheet.records.fetch \
  --form worksheet_name=Sheet1 --form header_row=3 --form count=100
# → { "records": [ { "PART NO": "39724SM", "PRICE": 12, "row_index": 5 } ],
#     "records_count": 11, "records_start_index": 1, "records_end_index": 3 }
```

Two traps. **`header_row` is effectively required** — omitting it fails with 2884; it does not default to row 1. And **a fetch stops at the first blank row**: on a worksheet with `used_row_index` 3408, `header_row=3` returned `records_count: 11` — rows 4–14, because row 15 was empty and a second header block began at row 16. `records_count` is that block's size, not the sheet's. For stacked blocks, fetch each by its own `header_row` or use `getrange` over the used area.

`count` is generous — `count=5000` returned all 1,054 records of a flat sheet, unclamped. `records_start_index` is a **1-based record ordinal, not a page** (`start=1` then `2` returns rows 4,5 then 5,6), so page with `start = 1, 1+count, …`; `workbook list`'s `start_index` is identical. `column_names` is a **comma-separated list**, and a wrong shape fails silently — `column_names=["PRICE"]` returns 200 with records holding nothing but `row_index`.

#### `criteria` — double quotes, `and`/`or`, no substring match

```bash
--form 'criteria="PRICE">10 and "PRICE"<15'
--form 'criteria="PART NO"="39724SM"'
```

Verified live: `=`, `>`, `<`, `!=` and `and`/`or` work, and column names with spaces are fine inside the double quotes. Rejected with 2895: `&&`, single-quoted or unquoted column names, and `like` / `.contains()` — there is **no substring operator**.

The same string drives `records update` and `records delete`; the `table` group names the field `criteria_json`, so a body copied between groups fails. Both take `first_match_only` — without it a loose criteria updates or deletes every match in one call.

#### Writes, import and export

Multi-row payloads are JSON **strings inside a form field**:

```bash
zone sheet content setcell <resourceId> --form method=cell.content.set \
  --form worksheet_name=Sheet1 --form row=2 --form column=3 --form content=12
zone sheet records add <resourceId> --form method=worksheet.records.add \
  --form worksheet_name=Sheet1 --form header_row=1 --form json_data='[{"PART NO":"X1","PRICE":9}]'
```

`records add` appends at the bottom; `content insertjson` and `structure insertrow` push the rest down, invalidating every `row_index` you already fetched — re-fetch after any insert or delete.

`workbook download` takes `format` `xlsx|pdf|csv|ods` and needs `--out` — the response is file bytes, not JSON. CSV export of a multi-sheet workbook silently gives one sheet, so pass `worksheet_name` when it matters. `workbook upload` takes a **`file_url`**, not a path; a local file needs a multipart POST through `zone api sheet`.

#### What you cannot undo

- `workbook delete` is permanent; `workbook trash` is the recoverable one (`workbook restore` brings it back) — one word apart. `worksheet delete` / `deletemany` and `structure deleterow` / `deleterows` / `deletecolumn` have no trash at all.
- `records delete` and `table delete` remove **every** row matching `criteria` — a typo that matches broadly is a silent mass delete. Run the same criteria through `records fetch` and count first.
- `content clearrange` wipes content **and** formatting (`clearcontent` keeps it); `content replace` rewrites across the whole `scope`; `workbook revertversion` overwrites current content with an older version — take `workbook createversion` first (`version_number` is a string like `"1.0"`).
- `workbook share`, `externalshare` and `publish` change who can see the data — `publish` makes it publicly reachable. `merge email` and `content fieldsmail` **send email**; they are not previews.

#### Read-only guards behave backwards here

Because every call is a POST, `ZONE_POLICY='*:GET'` blocks reads too (`Blocked by policy: sheet:POST …`) — a `*:GET` allow-list cannot express a Sheet read. `--dry-run` likewise returns a `dry_run` envelope for reads, so an agent running dry gets no data from this service at all.

#### Errors → what to do

| What you see | Meaning | Do |
|---|---|---|
| `HTTP 400`, no detail | zone prints only the status | re-run with `--verbose` for the `error_code` |
| 2831 `parameter [x] … missing` | no `--form method=…`; or `find`/`replace` without `scope` | add it — fields are in that command's `--help` |
| 2863 `The sheet does not exists` | bad `worksheet_name` | names are exact; take from `worksheet list` |
| 2876 `Invalid range reference` | bare cell or sheet-qualified address | `B2:B2`, never `B2` or `Sheet1!A1:C3` |
| 2884 `must contain … header row` | `records fetch` without `header_row` | pass it; there is no default |
| 2895 `criteria is not valid` | unquoted column, `&&`, or `like` | double-quote the column; use `and`/`or` |
| 2867 `method is not supported` | typo in the `method` value | check `--help` |
| **500** / 2866 `Internal server error`, exit 7 | nearly always a wrong `resource_id`, though zone calls 500 retryable | verify via `workbook list`; do not loop |
| 200, records hold only `row_index` | `column_names` given as a JSON array | use a comma-separated list |
| `records_count` ≪ `used_row_index` | the block ended at a blank row | fetch per `header_row`, or use `getrange` |
| exit 3 / exit 4 | not signed in / missing scope | `zone login sheet` — needs `ZohoSheet.dataAPI.READ`, `.UPDATE`, plus `WorkDrive.files.READ` for `workbook lock`/`unlock` |

> Command names, paths, required flags and the `method=` strings come from the installed specs, so they are exact. Everything else — the envelopes and prose timestamps, the out-of-order worksheet ids, 1-based indexing and the `range_address` rules, the sparse range response, string-vs-typed values, formulas returning computed values, the `header_row` and blank-row behaviour, the overlapping `start_index`, the silent `column_names` failure, the `criteria` vocabulary, every `error_code` above, the 500-on-bad-id case and the `ZONE_POLICY` / `--dry-run` interaction — was **verified live against a real Zoho Sheet account**. Only read commands were run; nothing was written, shared, published or deleted.
