Skip to content

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.

Endpointhttps://engineering.mcp.devfellowship.com/mcp
Tools13
Backing datawork.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.
Plan20260909-dfl-sheets-primitive
ToolWhat it does
create_sheetCreate the header and its tabs in one call. Returns the sheet, its tabs, the editor url and the {{dfl-entity:sheet:<uuid>}} token.
get_sheetRead the header plus every tab, or one tab, or an A1 slice of one tab. Optionally lists the version history without the payloads.
list_sheetsFind a sheet and its uuid. Filter by entity, by kind, or by name. Header rows only — no columns, no cells.
replace_sheet_tabBulk and destructive. Replace a tab’s whole columns + rows. Snapshots the whole sheet first, by default.
write_sheet_cellsSurgical and merging. Write individual cells; everything you did not name survives. All-or-nothing per batch.
snapshot_sheet_versionCheckpoint the whole sheet — every tab — into work.sheet_versions, without changing anything.
list_sheet_versionsThe history index: version number, commit message, author, date. Newest first, no payloads.
get_sheet_versionRead one stored snapshot back, whole or sliced to one tab.
copy_sheetTemplate → instance. Duplicate a sheet under a new name, row ids and all. clear_values makes a blank form out of a filled one.
import_sheetcsv, tsv, a GFM markdown table or a base64 .xlsx becomes a real sheet. One tab per worksheet for xlsx.
export_sheetThe 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.

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.

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.

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.

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.

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.

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_tab writes one before it destroys, unless you turn snapshot off. A snapshot that fails refuses the replace.
  • write_sheet_cells writes one only when you give it a commit_message — a snapshot per cell edit would bury the history it exists to keep.
  • snapshot_sheet_version writes 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.

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.

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.

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:

  • kind becomes table unless you pass one, because a copy of a template is an instance and not another template;
  • template_of is set to the source id, which is how the instance is traced back to the form it came from;
  • entity_id / entity_name are 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_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.

DirectionFormats
import_sheetcsv, tsv, markdown, xlsx_b64
export_sheetcsv, markdown, json, xlsx

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.

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.

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.

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.

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.

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.