Skip to content

Your workbook in git

Pull a workbook’s contents into your repository as files, put them through git diff / review / PRs, and push them back. Transforms written by the chat agent appear in the same place — so “review the SQL an AI wrote before it counts” becomes an ordinary workflow.

Terminal window
d2b pull --workbook WB # → transforms/ sheets/ charts/ + d2b.json
d2b pull --data customers # also track a small base table as data/customers.csv (export = branch)
git add -A && git commit -m "pull from D2B"
# ... edit transforms/*.sql|py, sheets/*.json, data/*.csv
d2b push --dry-run # what would be sent (only files that changed)
d2b push --commit "$(git rev-parse --short HEAD)" # apply the changes → pin the git sha as a named version
git commit -am "d2b push" # push updates d2b.json (sync hashes) — commit it too
DirectoryContentspullpush
transforms/SQL / Python transforms (nested paths like agg/monthly.sql work)✅✅ Only what changed, re-run via POST /transforms (metered as data operations), upstream first
sheets/Presentation sheets, {"blocks": [...]}✅✅ PUT /sheets/{name}
charts/Chart config + recipe (the tool and parameters that generated it)✅❌ Read-only — charts are re-generated from their recipe; a hand-edited config could never be refreshed. History and inspection
data/Opted-in base tables as CSV (__d2b_row_id carried), via --data✅ export = branch✅ Server-side row-id-keyed, cell-level 3-way merge. Cells changed on both sides land in the workbook’s conflict queue (D2B’s value stays)

--data only applies to base tables (tables whose rows are their own source of truth). mode=auto structuring output is derived (a transform’s output) and cannot branch — to round-trip a table’s rows through git, ingest it with --mode staged, review the parse spec and materialize (that produces a base table), or create it via the rows API.

d2b.json is the manifest. A transform entry is {name, artifact_name, args, layer, hash} (adding a new transform = the file plus this entry). hash is the digest at the last sync (the merge base), maintained by the CLI — commit d2b.json after a push.

When hand-writing a new entry, omit hash (leaving it out — or null — is the same): the first push assigns it and writes it back.

{
"workbook_id": "…",
"transforms": {
"agg/monthly.sql": {
"name": "agg/monthly",
"artifact_name": "product_sales", "args": {"src": "sales"}, "layer": null,
"hash": "…"
}
},
"sheets": {"summary.json": {"name": "summary", "hash": "…"}},
"charts": {"trend.json": {"name": "trend", "readonly": true, "hash": "…"}},
"data": {"customers.csv": {"table": "customers", "branch_id": "…", "hash": "…"}}
}

Hundreds or thousands of workbooks are handled as one repository = one workspace. The root d2b.json (the ledger) pins the workspace, and every workbook lands in workbooks/<title>--<first 8 of its id>/ with the layout above, its own d2b.json included.

Terminal window
d2b pull --workspace WS # first time: writes the ledger and pulls every workbook of the workspace (in parallel, --jobs N)
d2b pull # afterwards: from the ledger; a new workbook appears by itself
d2b pull --prune # remove the directories of workbooks that left the workspace (the removal shows in the diff)
d2b status --strict # exit 2 when the ledger, the directories and the server disagree (make it a required CI check)
d2b push --commit "$(git rev-parse --short HEAD)" # only the workbooks that changed are sent
  • Membership is the server’s fact: pull lists every workbook of the workspace and never writes a directory the ledger does not know. Another workspace cannot be pulled into the same repository — there is no override flag.
  • A workbook’s directory is fixed at its first pull; a retitle does not move it. Its id is in the directory name and on the first line of every transform file, -- d2b ws=… wb=… transform=… (the line is never sent to the server and never counts as a change).
  • push sends nothing at all when it finds a directory the ledger does not know, a file whose header names another workbook, or a manifest bound to a different workbook_id.
  • Give CI a PAT pinned to the workspace (workspace_id on POST /api/control/account/keys, or resource: "workspace:<id>" on POST /api/v1/me/tokens): a misconfigured checkout still gets a 403 from the server for any other workspace. A d2b login credential reaches the whole account and is not the right key for CI.
  • data/ (rows) stays a per-workbook opt-in (d2b pull --data TABLE inside workbooks/<dir>/).

A directory without a ledger keeps the single-workbook behaviour above.

  • Text sections (transforms / sheets / charts) never overwrite silently (the same contract as the row API’s optimistic lock): if both sides moved since the last sync, pull and push are both refused with the file list as JSON. --force points differently per command: push --force overwrites with your local side; pull --force takes the server side (discarding local edits)
  • data/ is different: concurrent edits are arbitrated cell-by-cell by the server’s 3-way merge, so nothing is refused. After a push, the CSV is re-fetched with the merged result and a fresh branch is cut (D2B-side changes land locally too). Derived tables cannot branch (400). Merges cap at 100,000 rows — this is for small tables like masters and mappings
  • Files deleted locally never delete anything on the server (reported as deleted_locally). --prune deletes transform output tables and sheets (table deletion needs workbooks:delete). data/ is only untracked — the table stays
  • d2b.json keys must be normalised relative paths inside their own section directory: absolute paths, .., Windows drives and un-normalised paths are refused on both pull and push. A synced file that is a symbolic link (transforms/*.sql|py and the like) is refused too — nothing is read or written through a link (a link to a file the section never reads is simply ignored)

No GitHub App and no D2B-side integration — the CLI alone closes the loop between a repository and a workbook.

Terminal window
d2b github-workflow > .github/workflows/d2b.yml
# Secrets: D2B_API_KEY (a workbooks:write PAT). Variables: D2B_BASE_URL
  • Merge to main (touching synced files) → d2b push --commit <sha> → the refreshed d2b.json is committed back automatically
  • On a schedule (hourly by default) and on demand → d2b pull → a PR when something changed (d2b/pull branch). What the agent changed in the workbook gets human review before it merges

In the SDKs, the same surface is client.transforms.list(wb) / client.sheets.list(wb) / client.charts.list(wb) / client.export.branch(wb, [table]) / client.tables.merge(...).