Spredin

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') Build a dynamic (re-editable) pivot into a new sheet. 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).
sheet.to_json([range]) Range / used sheet as a JSON string (2D array of values).
sheet.paste_csv(text, at='A1', sep=',') Parse CSV/TSV text and write it in at a cell.

Opening / saving actual files is a menu action (File → Open / Export). Inside a script, read() gives you the data and to_csv / to_json hand it back out as text — an agent can persist or transmit that however it likes.

Gotchas worth knowing:

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.
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/…), each acting on its own sheet — so a script can read several sheets and write results to another:

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

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 / TODAY compute 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)#

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.)