sheets
A spreadsheet as a first-class DFL artifact, the way a diagram is. An agent
writes it here, a person edits the same rows at
sheets.devfellowship.com, a plan embeds it, and a proposal can point an answer
at one.
| Endpoint | https://engineering.mcp.devfellowship.com/mcp |
| Tools | 13 |
| Backing data | work.sheets (the header), work.sheet_tabs (one row per tab), work.sheet_versions (whole-sheet snapshots). Applied by dfl-schema #955 on 2026-09-09. |
| Plan | 20260909-dfl-sheets-primitive |
| Tool | What it does |
|---|---|
create_sheet | Create the header and its tabs in one call. Returns the sheet, its tabs, the editor url and the {{dfl-entity:sheet:<uuid>}} token. |
get_sheet | Read the header plus every tab, or one tab, or an A1 slice of one tab. Optionally lists the version history without the payloads. |
list_sheets | Find a sheet and its uuid. Filter by entity, by kind, or by name. Header rows only — no columns, no cells. |
replace_sheet_tab | Bulk and destructive. Replace a tab’s whole columns + rows. Snapshots the whole sheet first, by default. |
write_sheet_cells | Surgical and merging. Write individual cells; everything you did not name survives. All-or-nothing per batch. |
snapshot_sheet_version | Checkpoint the whole sheet — every tab — into work.sheet_versions, without changing anything. |
list_sheet_versions | The history index: version number, commit message, author, date. Newest first, no payloads. |
get_sheet_version | Read one stored snapshot back, whole or sliced to one tab. |
copy_sheet | Template → instance. Duplicate a sheet under a new name, row ids and all. clear_values makes a blank form out of a filled one. |
import_sheet | csv, tsv, a GFM markdown table or a base64 .xlsx becomes a real sheet. One tab per worksheet for xlsx. |
export_sheet | The inverse: csv, markdown, json or xlsx (base64). |
There is no delete_sheet, the same as for diagrams. There is also no tool that
edits or deletes a version; see History.
The shape
Section titled “The shape”A sheet is a header plus tabs, and a tab is one document: an ordered
columns list and an ordered rows list.
{ "columns": [ { "key": "rubrica", "label": "Rubrica", "type": "text" }, { "key": "quantidade", "label": "Quantidade", "type": "number" }, { "key": "valor_unitario", "label": "Valor unitário", "type": "currency" }, { "key": "total", "label": "Total", "type": "currency" } ], "rows": [ { "id": "9f2c…", "cells": { "rubrica": { "v": "Bolsas" }, "quantidade": { "v": 3 }, "valor_unitario": { "v": 4000, "fmt": "BRL" }, "total": { "v": 12000, "f": "=B2*C2" } } } ]}A cell is {v, f, t, fmt} — value, formula, per-cell type override, display
format. A cell may hold both f and v: the formula plus its last computed
value, so a reader sees a number without evaluating anything. Nothing in this
MCP evaluates a formula; the editor does.
type is one of text, number, currency, percent, date, boolean,
formula, select. The list is closed on purpose — the grid has to render
every one of them, so a new type is a code change rather than a string an agent
invents.
One row per tab, not one row per cell
Section titled “One row per tab, not one row per cell”A ten-tab budget is not one blob that two writers lose updates on: each tab is
its own row and its own atomic write. The cost of that choice is that changing
one cell is a read-modify-write, which is exactly why write_sheet_cells exists
instead of leaving callers to rebuild rows themselves.
Rows are semi-structured
Section titled “Rows are semi-structured”A cell key that no column declares is stored, not rejected — and it is also
invisible in the editor, which renders by column. Every write names such a key
back in unmapped_cell_keys. Treat it as a typo in the column key until proven
otherwise, and add the column when the data is real.
A1 addressing
Section titled “A1 addressing”Row 1 is the header — the line of column labels the grid shows. So A1 row 2
is rows[0], the first data row. Getting that backwards writes every value one
row up, and the sheet still looks plausible.
Column letters are positional: B is the second column of the tab, whatever
its key.
B2— one cell.B2:D10— a block.B:D— whole columns, every row.2:10— whole rows, every column.
Lowercase and $ anchors are accepted, and a reversed range (D10:B2) reads the
same as a forward one — as a drag that starts bottom-right does. A mixed
range such as B2:D is refused rather than guessed at: a spreadsheet would
silently promote it to whole columns, and an agent that meant B2:D10 would read
the wrong slice and never know.
A range slices one tab, so pass tab with it. The result echoes
range_applied, and each returned row keeps its original data-row index in
row_indexes — so an index read from a slice still addresses the right row in a
write.
The two writers are not interchangeable
Section titled “The two writers are not interchangeable”This is the one confusion here that loses data.
replace_sheet_tab is the bulk path. It replaces a tab’s whole contents, so
whatever the tab held and you did not send is gone — including rows a person
edited in the app since your last read. Use it when you generate or regenerate a
whole table. Because it destroys, it writes a whole-sheet snapshot to
work.sheet_versions first; snapshot defaults to true, and a snapshot that
fails refuses the replace rather than proceeding without its safety net. The
snapshot covers every tab, not only the one being written, because a budget is
read across its tabs and a one-tab checkpoint is not a state anyone can return
to.
write_sheet_cells is the surgical path. It merges: every cell you do not
name survives, and so does every field (t, fmt) of a cell you name but only
partly write. A value written without a formula clears that cell’s
formula, the way typing a number over one does in a spreadsheet, so the stored
value and the stored formula never disagree.
A missing row is created. An unknown row id creates a row under that id, which makes a retry of the same call idempotent instead of appending a second copy; a numeric index past the end appends empty rows up to it, so the index means what it says.
Its batch is all-or-nothing: one bad address refuses the whole call and the database is not touched. The result names, per write, the row id, the row index and the A1 address it landed on — read it back to confirm you addressed what you meant.
Snapshots differ between the two on purpose. write_sheet_cells writes a version
row only when you give it a commit_message, because a snapshot per cell
edit would bury the history it exists to keep.
History
Section titled “History”work.sheet_versions holds whole-sheet snapshots: the header plus every tab,
with all their columns and rows. A one-tab checkpoint is not a state anybody can
return to, because a budget is read across its tabs.
Three ways a version row gets written, and they do not overlap:
replace_sheet_tabwrites one before it destroys, unless you turnsnapshotoff. A snapshot that fails refuses the replace.write_sheet_cellswrites one only when you give it acommit_message— a snapshot per cell edit would bury the history it exists to keep.snapshot_sheet_versionwrites one and changes nothing else. That is the tool for a state worth keeping when no write is coming: the budget an ADR was decided against, a template before somebody starts editing it.
version_number is MAX(version_number) + 1 for the sheet — not a count.
Two writers can compute the same next number at the same moment (a
replace_sheet_tab on tab A racing one on tab B is a normal thing to do), so
the UNIQUE (sheet_id, version_number) collision is retried against a
recomputed maximum instead of failing opaquely.
Reading it back
Section titled “Reading it back”list_sheet_versions is the index: number, commit message, author, date,
newest first, and deliberately no snapshot payload — fifty versions of a
ten-tab sheet is a whole spreadsheet fifty times over. get_sheet with
include_versions gives the same list beside the content; use the dedicated
tool when you want the history alone, or when you need to page past the newest
50.
get_sheet_version is what makes a prior state actually recoverable: it
returns the payload as stored. Pass tab to slice it to one tab by name.
Append-only, on purpose
Section titled “Append-only, on purpose”The history is SELECT + INSERT under RLS (lane L1 of the plan grants nothing else). There is no tool that edits or deletes a version row, and there is not going to be one.
So a restore is a normal write: read the state with get_sheet_version and
put it back with replace_sheet_tab, one tab at a time. That write leaves its
own version row behind, which means the state you replaced does not disappear
either — undo has an undo.
Template → instance
Section titled “Template → instance”copy_sheet is the path an edital’s budget template takes to become one
submission’s answer. It duplicates everything — every tab in order, its columns,
its meta and all of its rows, with the row ids preserved, so a reference to
a row of the template still resolves in the copy and a later diff against the
template lines up. The source is never written, so the next submission starts
from the same blank form.
Three things differ on purpose:
kindbecomestableunless you pass one, because a copy of a template is an instance and not another template;template_ofis set to the source id, which is how the instance is traced back to the form it came from;entity_id/entity_nameare inherited from the source only when you pass neither. Pass the submission, the epic or the plan slug the copy belongs to, or it stays filed under the template.
What clear_values takes, and what it keeps
Section titled “What clear_values takes, and what it keeps”It removes v from every cell whose column declares no formula. It keeps the
columns, the row ids, every cell formula (f), and the t / fmt of every
cell. A column that has a formula keeps its values, because those cells are
computed rather than typed and the editor recomputes them — blanking them would
write over a cell nobody types in.
Import and export
Section titled “Import and export”import_sheet and export_sheet are inverses. An xlsx written by the second
re-imports through the first into the same columns and the same values —
formulas included, because an xlsx cell holds a formula and its cached
result, exactly as a DFL cell does.
| Direction | Formats |
|---|---|
import_sheet | csv, tsv, markdown, xlsx_b64 |
export_sheet | csv, markdown, json, xlsx |
The first row is the header
Section titled “The first row is the header”Every column key is minted from it: accents folded, lower case, a single _
between words. So Valor unitário becomes valor_unitario — and the same
function runs on the way out, which is what makes the round trip close. A
repeated header becomes total, total_2; a blank one becomes column_3.
The result returns key_map, header text to key, because those keys are what
write_sheet_cells addresses afterwards. A file whose first row is data
imports that data as the column names, and the sheet still looks plausible —
read key_map back before you write.
Values are inferred; types are not carried
Section titled “Values are inferred; types are not carried”A field that reads exactly as a number becomes a number, true / false become
booleans, and a field starting with = becomes a formula. Everything else stays
text on purpose: 007 keeps its leading zero and 1,234 keeps its separator,
because a leading zero is how an id is written and a thousands separator is a
locale the file does not declare.
No file format carries a DFL column type, so a column comes back as number,
boolean, formula or text. currency, percent and date do not survive
an import — set them afterwards with replace_sheet_tab when the display
matters.
On the way out
Section titled “On the way out”Formulas render as their values in csv, markdown and json, because the
value is the number a reader wants; a cell that has a formula and was never
computed falls back to the formula text. xlsx is the exception and writes both.
csv and markdown render one tab — pass tab, or omit it when the sheet
has only one. json and xlsx cover every tab when you omit tab. json
gives one object per row keyed by column key, plus _row_id (prefixed so it
cannot collide with a column genuinely keyed id). xlsx comes back as base64
in content_base64; nothing here writes a file.
To read a sheet in order to write it, use get_sheet rather than
export_sheet — none of these formats carries the cell objects or the addressing
that write_sheet_cells needs.
The library
Section titled “The library”xlsx is exceljs 4.4.0, MIT
(ADR-6 of the plan; the licence was verified from the package at the time it was
added). It is imported lazily, inside the two functions that need it, so neither
a server boot nor the docs generator loads a spreadsheet engine to read tool
metadata.
Putting a sheet in a plan
Section titled “Putting a sheet in a plan”Do not hand-write the token. Call the plans MCP attach_entity with the plan
slug, type sheet and the uuid as the locator. It inserts
{{dfl-entity:sheet:<uuid>}}, which the plans-app resolves at render time — so
later edits to the sheet reach the plan with no new plan version.
Visibility
Section titled “Visibility”Reads run under the caller’s own RLS, so missing and not visible to you are the same answer and the tools say so rather than claiming the sheet is gone.
Unlike a diagram, a sheet with no entity is not hidden from the group.
public.diagrams uses entity_id IS NOT NULL as a de-facto “shared” flag, which
makes an unparented diagram readable by its creator alone; sheets are read by
membership instead (ADR-3 of the plan). entity_id here is a grouping key —
what list_sheets filters on and what the plans-app sidebar groups by — not a
visibility switch.
Ordering
Section titled “Ordering”list_sheets orders by work.sheets.updated_at. Nothing in the database bumps
that column when only a tab changes, so both writers here touch the parent after
a successful write and the order reflects the last content change. An edit
made in the app writes the tab directly and does not bump it; a trigger in
dfl-schema would be the proper fix for that path.