Scripting API reference
This is the same document you'll find inside Spredin under Help.
The single, stable contract for driving Spredin by code instead of by clicking
the UI. An LLM (local or cloud) reads this as context and writes Python against the
global sheet; the spreadsheet is both the data source and the canvas.
The exact same text is injected into the AI's system prompt and shown in the in-app help, and a test asserts every method here exists in the runtime — so this never drifts and the model is never told about a method that doesn't exist.
Model#
Think in normal Python + pandas. numpy / pandas / scipy are installed but
not imported for you — write import pandas as pd yourself (using pd without it
is a NameError).
sheet is the active sheet — already defined; never reassign it. Write top-level
code (no def main(), no if __name__).
Row numbering — three systems, don't mix them#
This is the single most common way a script goes wrong (an IndexError, or a
formula written into the header row):
| counts from | includes the header? | |
|---|---|---|
sheet.set(r, c) / value / get |
0 — row 0 is the top row | yes: row 0 usually is the header, so data starts at row 1 |
sheet.rows |
— | yes: data rows are 1 .. sheet.rows-1 |
sheet.read() → DataFrame |
0 | no — the header became the column names, so len(df) == sheet.rows - 1 and df.iloc[i] is sheet row i+1 |
A1 labels inside "=…" formulas |
1 | n/a — sheet row r is spreadsheet row r+1 |
for r in range(1, sheet.rows): # data rows, 0-indexed
sheet.set(r, 3, '=B%d+C%d' % (r+1, r+1)) # A1 rows are r+1
same_row_in_df = df.iloc[r - 1] # df has no header row
Never index a DataFrame with a sheet row number — that is off by one and runs off the end.
Methods#
Read / write (pandas-first)#
| Call | Returns / effect |
|---|---|
sheet.read([range]) |
Used range (or "A1:C20", or a sheet name) as a DataFrame (row 1 → column names). |
sheet.read_values(range) |
A range as a plain 2D list (no pandas). |
sheet.write_col(col, values[, start_row=1]) |
Write a list down a column. col takes an index, a letter ('E') or a start cell ('E2'). |
sheet.write_row(row, values[, start_col=0]) |
Write a list across a row. |
sheet.write(data[, at='A1']) |
Write a whole DataFrame / Series / 2D-list at a top-left cell in one call (never loop row-by-row). |
Cells#
| Call | Returns / effect |
|---|---|
sheet.value(row, col) / sheet.value('A1') |
Computed value of one cell (None if empty). |
sheet.get(row, col) / sheet.cell('A1') |
RAW text of one cell — a formula comes back as its "=…" source, not its result. |
sheet.set(row, col, v) / sheet.set_cell('A1', v) |
Write one cell; a "=..." string is a live formula. |
sheet.rows, sheet.cols |
Used size (ints). |
sheet.col_letter(col) |
Column index → letter (0 → 'A', 26 → 'AA'). Use this rather than writing your own. |
sheet.a1(row, col) |
0-based (row, col) → an A1 label: sheet.a1(2, 1) → 'B3'. |
sheet.used_range |
The used area as an A1 range ('A1:E5') — pass straight to pivot / cond_format / sort. |
Spredin ops (call the app engine — no pure-Python equivalent)#
| Call | Effect |
|---|---|
sheet.chart(range, kind, title) |
ECharts chart bound to a range; kind: bar/line/area/pie/scatter. |
sheet.merge(range, combine=False) |
Merge cells (combine=True joins their text). |
sheet.unmerge(range) |
Split any merged cells inside a region. |
sheet.style(range, bold=, italic=, fill=, color=, align=, number_format=) |
Cell styling / number format. |
sheet.sort(range, by=[(col_offset, ascending), …]) |
Sort a range's rows in place, e.g. sheet.sort('A2:F9', by=[(5, False)]). |
sheet.freeze(rows=0, cols=0) |
Freeze panes — freeze(1) pins the header row; freeze(0, 0) unfreezes. |
sheet.cond_format(range, kind, value=/min=/max=/text=/n=, fill=) |
Conditional formatting: 'greaterThan', 'between', 'textContains', 'top', 'duplicates', 'colorScale', 'dataBar'… |
Structure (whole rows / columns — 0-indexed; formulas rebase)#
| Call | Effect |
|---|---|
sheet.insert_rows(at, count=1) |
Insert blank rows before row index at. |
sheet.delete_rows(at, count=1) |
Delete rows from index at (refs into them → #REF!). |
sheet.insert_cols(at, count=1) |
Insert blank columns before column index at. |
sheet.delete_cols(at, count=1) |
Delete columns from index at. |
sheet.set_col_width(col, px) / sheet.set_row_height(row, px) |
Resize a column / row (pixels). Sizes are clamped to a minimum — this cannot hide. |
sheet.hide_cols(at, count=1) / sheet.hide_rows(at, count=1) |
Hide whole columns / rows (0-indexed). The only way to hide — set_col_width(c, 0) leaves it visible. |
sheet.unhide_cols(at, count=1) / sheet.unhide_rows(at, count=1) |
Show hidden columns / rows in the span [at, at+count). |
Data tools (Data menu)#
| Call | Effect |
|---|---|
sheet.filter(range, column, op, value) |
AutoFilter — hide rows whose column (offset within range) fails op ('='/'!='/'>'/'>='/'<'/'<='/'contains'/'startsWith'/'endsWith'/'empty'/'notEmpty'). Returns rows hidden. |
sheet.clear_filter([range]) |
Unhide all rows. |
sheet.remove_duplicates(range) |
Drop duplicate rows (keeps first). Returns count removed. |
sheet.pivot(range, rows=['Region'], values=[('Q1','sum')], cols=None, filters=None, show_as='value', name=None) |
Build a dynamic (re-editable) pivot into a new sheet, named Pivot of <source sheet> unless name= says otherwise. Fields are header names or 0-based offsets. rows = nested row fields; values = fields or (field, agg) pairs (sum/count/average/min/max); cols = one column field; filters = (field, value) or (field, op, v1[, v2]); show_as 'value'/'pctOfTotal'. Returns the new sheet handle. The legacy pivot(range, row_field, value_field, agg, col_field) positional form still works. |
pivot_sheet.update_pivot(rows=, values=, cols=, filters=, show_as=) |
Reconfigure a pivot sheet in place (same args as pivot) and rebuild from its live source — the Excel field-pane edit, from code. |
pivot_sheet.refresh_pivot() |
Recompute a pivot sheet from its (possibly changed) source, keeping the same configuration. |
sheet.validate(range, kind, op=, f1=, f2=, options=, error=) |
Data validation — kind 'list' (options='A,B,C' or a range), 'whole'/'decimal'/'date' (op + f1[,f2]), 'textLength'. |
sheet.clear(range) |
Clear cell contents (styling kept). |
sheet.define_name(name, target) |
Define a workbook named range, e.g. define_name('Revenue', 'B2:B13'). |
Import / export (in-memory — no file dialog)#
| Call | Returns / effect |
|---|---|
sheet.to_csv([range], sep=',') |
Range / used sheet as CSV text (computed values; dates as the dates they show, e.g. 2024-01-15). |
sheet.to_json([range]) |
Range / used sheet as a JSON string (2D array of values; numbers as numbers, dates as the dates they show). |
sheet.paste_csv(text, at='A1', sep=',') |
Parse CSV/TSV text and write it in at a cell. |
Saving files is a menu action (File → Save / Export). Inside a script,
read()gives you the data andto_csv/to_jsonhand it back out as text — an agent can persist or transmit that however it likes.
Reading another workbook — await open_file(path)
Opens a .sprd, .xlsx, .xls, .csv or .tsv from disk as data, without
a window and read-only. Note the await.
The first time a script names a file you have not opened yourself in this session, Spredin asks you, showing the full path, before it reads it — Spredin opens only files you choose. Refusing raises an error in the script. A file you opened with File → Open or from the Finder is not asked about again.
wb = await open_file('/Users/me/Reports/jan.xlsx')
print(wb.sheets) # ['Summary', 'Raw']
total = wb.sheet('Summary').cell('B12') # computed value, like sheet.value
rows = wb.sheet('Raw').read_values() # every cell as a 2-D list
df = wb.sheet('Raw').read() # a DataFrame, row 1 as the header
wb.close()
| Call | Returns |
|---|---|
wb.sheets / wb.name |
Sheet names / the file name. |
wb.sheet(name=None) |
A read-only sheet (the first one when no name is given). |
.value(r, c) / .cell('A1') |
One computed value (0-based r, c, or an A1 label). |
.rows / .cols / .read_values() / .read() |
Shape, every value, or a DataFrame. |
wb.close() |
Release it (a with await open_file(…) as wb: block does this for you). |
Formulas in the file compute against that file's own sheets and defined names.
A CSV is converted the way File → Open converts it ($1,200 → 1200, dates →
dates). A large .xlsx is read in full, so it can take a few seconds.
Gotchas worth knowing:
filtertreats row 1 of the range as data. Pass the range below the header (e.g.filter('A2:J5001', …)), or the header row will be tested and hidden too.write_col/write_rowtake a 0-based integer index, a column letter (write_col('E', …)), or a start cell (write_col('E2', …)/write_row('B6', …)).- New sheets and named ranges become visible only after the script finishes.
Sheets you create with
add_sheet/pivotare fully writable within the run (write to the returned handle), but a formula that references a brand-newdefine_name(=SUM(NewName)) won't resolve until the run commits — do the name-based math in a later run, or compute it in Python.
Sheets#
| Call | Effect |
|---|---|
sheet.add_sheet(name, replace=False) |
Create and return a new sheet handle. replace=True reuses and empties an existing sheet of that name. Excel's naming rule applies: at most 31 characters and none of : \ / ? * [ ], or the call raises an error that says so. |
sheet.delete_sheet(name) |
Delete a sheet by name. Returns False if it does not exist, or is the last sheet. |
sheet.charts() |
This sheet's charts as dicts {id, range, type, title}. |
sheet.clear_charts() |
Remove every chart from this sheet; returns how many were removed. |
Writing a script you can run twice#
Scripts are not idempotent by default: add_sheet('Report') on a second run
returns a differently named sheet, and charts are append-only, so re-running
stacks a new chart card on the old one. Two arguments fix both:
out = sheet.add_sheet('Report', replace=True) # reuse + empty, don't pile up
out.clear_charts() # drop last run's charts first
out.write(rows, 'A1')
out.chart('A1:B10', 'bar', 'Revenue')
| sheet.copy_to(dest[, range][, at='A1']) | Copy this sheet's data (or a range) into another sheet handle. |
| sheet.book(name) | Get an existing sheet by name. |
| sheet.names() | List all sheet names. |
| sheet.name | This sheet's name. |
Cross-sheet (combine multiple sheets)#
Every handle from sheet.book(name) / sheet.add_sheet(name) supports the same
methods (read/write/value/set/chart/style/
import pandas as pd
a = sheet.book('Sales').read()
b = sheet.book('Targets').read()
merged = a.merge(b, on='Region')
out = sheet.add_sheet('Combined')
out.write(merged)
Formulas can reference other sheets directly too:
sheet.set('B2', "=Sales!B2-Targets!B2").
Number formats (number_format=)#
The vocabulary is Excel-ish except for dates, which use keywords. An
unrecognised code is written into the cell verbatim — so
number_format='yyyy-mm-dd' silently displays the literal text yyyy-mm-dd
instead of a date. This block is injected into the LLM prompt for the same reason.
| Code | Result |
|---|---|
'general' |
plain number (the default) |
'date' / 'time' / 'datetime' |
the only way to show a date. Dates are serials (days since 1899-12-30); format the serial with 'date'. Excel picture codes (yyyy-mm-dd, mm/dd/yyyy, dd-mmm-yy) are not understood. |
'0', '0.00', '#,##0', '#,##0.00' |
integers / fixed decimals / thousands |
'$#,##0', '€#,##0.00' |
leading symbol = currency |
'0%', '0.00%' |
percent (scales by 100) |
'0.00E+00' |
scientific |
'$#,##0;[Red]($#,##0)' |
; splits positive;negative;zero — red parens for negatives |
Dates from a script#
Write datetime.date / datetime.datetime objects, pandas Timestamps, numpy
datetime64 values or ISO text ('2024-01-15', '2024-01-15T14:30:00') and the
cell becomes a real date — a serial with the 'date' (or 'datetime') format
already applied, so =F2+30, sorting and charts work. That is what Excel's VBA
does on write. A locale form such as '1/2/2024' is not guessed at and stays
text, and a cell that already has a date format keeps it. Reading a date cell back
(sheet.value, sheet.read) gives the serial; pd.to_datetime(serial, unit='D', origin='1899-12-30') turns it into a Timestamp.
Formatting never changes the stored value, only its display.
Formula functions (inside "=…" strings)#
sheet.set(r, c, '=…') writes a live formula that the engine recalculates.
The engine implements a specific set of Excel functions — anything outside it
evaluates to #NAME?. The catalogue is generated from the engine itself, so it
cannot drift out of date, and the full list with signatures is injected into
the LLM prompt automatically. Adding a function without documenting it fails
the build, so what the model is told is always what the engine actually has.
Covered, by family: math/trig (SUM ROUND ABS LN LOG EXP PI SIN…),
stats (AVERAGE MEDIAN STDEV PERCENTILE NORM.DIST RANK…), lookup
(XLOOKUP VLOOKUP INDEX MATCH INDIRECT OFFSET-free), logical (IF IFS
IFERROR AND OR SWITCH), text (LEFT MID TEXTJOIN TEXTBEFORE SUBSTITUTE
TEXT), date/time (TODAY EOMONTH NETWORKDAYS YEARFRAC WEEKNUM), finance
(NPV IRR XIRR PMT IPMT SLN DDB), engineering (DEC2BIN BITAND…) and
info (ISNA ISTEXT ERROR.TYPE). Browse them in-app by typing = in a cell.
Volatile caveat:
RAND/RANDBETWEEN/NOW/TODAYcompute once and cache (dependency-aware recalc has nothing to invalidate them on). Re-edit the cell to reroll.
Patterns#
# Analyze
df = sheet.read()
sheet.write(df.describe(), 'H1')
# Derived formula column (live, recalculates)
for r in range(1, sheet.rows):
sheet.set(r, sheet.cols, '=B%d*C%d' % (r + 1, r + 1))
# Chart
sheet.chart('A1:C13', 'bar', 'Revenue by region')
# Copy a sheet (two lines, no loop)
dst = sheet.add_sheet('Copy')
dst.write(sheet.read())
# Restructure + tidy
sheet.insert_cols(2) # new blank column C
sheet.set(0, 2, 'Margin')
sheet.filter('A1:F100', 5, '>', 1000) # hide rows where col F <= 1000
sheet.remove_duplicates('A2:F100')
sheet.freeze(1) # pin the header row
# Pivot to a new sheet by header NAME, then chart it
pv = sheet.pivot('A1:E5', rows=['Region'], values=[('Q1', 'sum'), ('Q2', 'sum')])
pv.chart('A1:C6', 'bar', 'Q1 vs Q2 by region')
# Re-edit the pivot in place (Excel field-pane edit, from code):
pv.update_pivot(rows=['Region'], values=[('Q1', 'sum')], cols='Region', show_as='pctOfTotal')
pv.refresh_pivot() # recompute if the source data changed
# Validation + named range
sheet.validate('B2:B100', 'list', options='Low,Medium,High')
sheet.define_name('Revenue', 'C2:C100')
sheet.set('E1', '=SUM(Revenue)')
# Hand data back out as text (no file dialog)
print(sheet.to_csv('A1:C10'))
Rules (for correct, hallucination-free output)#
- Call only the methods above — anything else raises a clear "no such method".
sheet.write(...)places a whole table at once;sheet.set(r, c, v)is for a single cell only. Never loop to write a DataFrame.- Don't
importthe app; don't reassignsheet. - Chart with
sheet.chart(...), never matplotlib /df.plot().
Agent control API — window.spredin (paste & run code into the panel)#
The sheet API above is what runs inside a Python script. To let an external
agent/LLM put code into the lower Python panel and run it (no UI clicking),
the app also exposes a small SDK on window.spredin (browser + Tauri webview):
window.spredin.version // workbook/format version (number)
window.spredin.script.show() // open the Python panel
window.spredin.script.hide() // close it
window.spredin.script.get() // current code in the active file (string | null)
window.spredin.script.set(code) // replace the active file's code (async; opens panel)
window.spredin.script.append(code) // append to the active file (async; opens panel)
window.spredin.script.run(code?) // run; if `code` given, set it first.
// → Promise<{ ok, error?, output }>
window.spredin.script.output() // captured stdout from the last run (string)
window.spredin.script.files() // file names (string[])
window.spredin.script.activeFile() // active file name (string | null)
window.spredin.script.newFile(name?, code?) // create+select a file → its name (async)
window.spredin.script.select(name) // select a file by name → found? (async)
Mutating calls (set/append/run/newFile/select) auto-open the panel and
wait for it to mount, so they work even if the panel is hidden. The Python you paste
uses the sheet API documented above. Example:
const r = await window.spredin.script.run(`
import pandas as pd
df = sheet.read()
sheet.write(df.describe())
`);
console.log(r.ok, r.output);
Everything below is also what the AI writes against; there is no second, lesser API for automation.
Roadmap (planned additions — not yet available)#
These are planned; they are not callable yet and
must not be used until shipped: sheet.sql(query) (DuckDB over a DataTable),
sheet.db(url, query) (read-only connector → DataFrame).
(sheet.sort, sheet.freeze, sheet.cond_format, sheet.pivot, filter,
remove_duplicates, validate, define_name and the structure / import-export
methods HAVE shipped — see the tables above.)