# D2B for Agents Source: https://docs.d2b.dev/en Spreadsheets for AI Agents — take messy Excel in at the door, hand your agent typed, versioned, governed tables, and return results to humans as Excel in its original formatting. D2B is "Spreadsheets for AI Agents". It takes messy, human-made Excel at the door, hands your agent **typed, row-identified, versioned, governed** tables, and returns results in a form humans can read (xlsx, or the original file's own formatting). Not just answers — **every number is traceable to where it came from**. ## Three ways in | Path | Best for | First call | |---|---|---| | [MCP](/en/mcp) | MCP hosts — Claude Code / Claude Desktop / Cursor / VS Code | `claude mcp add d2b --transport http https://d2b.dev/mcp/ --header "Authorization: Bearer $PAT"` | | [CLI](/en/cli) | Shell-driving agents, CI, humans | `pipx install d2b-sdk && d2b login` | | [SDK / API](/en/sdks) | Your own agents and apps (Python / TypeScript / HTTP) | `pip install d2b-sdk` → [Quickstart](/en/quickstart). [PyPI](https://pypi.org/project/d2b-sdk/) / [npm](https://www.npmjs.com/package/d2b-sdk) / [GitHub](https://github.com/600/d2b-sdk) | All three are the same surface on the same backend. MCP tools, CLI subcommands, SDK methods and REST endpoints map one-to-one — start anywhere and switch freely. ## What makes it different - **Traceable**: derived tables are built with transforms (SQL / Python); `{{ arg }}` bindings keep lineage. A code interpreter answers but can't be traced or reproduced. - **Revertible**: snapshots, named versions, an op log and undo. Built for agents that make mistakes — restore any point in time. - **Governed**: column-tag × role mask / deny enforced in the data layer. The same policy applies to SQL, export and MCP alike. - **Deliverable**: xlsx / csv, write-back into the original workbook with only value cells replaced, live per-row formulas, export = branch → re-import = 3-way merge. - **Lives in git**: pull transforms, sheets, charts and base tables into a repository, review them in a PR, push them back ([Your workbook in git](/en/git-sync)). ## Where to go next 1. [Quickstart](/en/quickstart) — mint a PAT and make your first call in five minutes 2. [Core concepts](/en/concepts) — Workbook / Source / Table / Sheet / Version, optimistic locking, idempotency, governance 3. Pick a path: [MCP](/en/mcp) / [Coding agents](/en/agents) / [CLI](/en/cli) / [SDKs](/en/sdks) / [Webhooks](/en/webhooks) 4. [Reading errors](/en/errors) — problem+json and `suggested_fix` 5. [API reference](/en/api-reference) — generated from the OpenAPI spec Agent-facing indexes: [llms.txt](https://docs.d2b.dev/llms.txt) / [llms-full.txt](https://docs.d2b.dev/llms-full.txt) / [openapi.json](https://docs.d2b.dev/openapi.json) --- # Use D2B from a coding agent Source: https://docs.d2b.dev/en/agents Hand D2B to Claude Code / Codex / Cursor and friends: connect over MCP or the CLI, and let the server deliver the operating norms. Least-privilege PATs, the minimal repo snippet, faithful revise, live formulas. D2B has two paths for agents. **MCP** (tool calling: Claude Code / Claude Desktop / Cursor / VS Code and others) and the **CLI** (`d2b`: shell-driven agents, git sync, batch work). Both expose the same surface: JSON out, errors with a `suggested_fix`, an idempotency key on every mutation, no interactive prompts. **The operating norms come from the server.** When a host connects over MCP, the initialize response carries `instructions` — which tool when, in what order — and the same text is readable as the `d2b://guide` resource. You do not paste a long procedure into your repo; you paste the connection and the choice of path (below). ## Setup (the human's part) 1. Mint a least-privilege PAT — for an agent, a workbook-scoped token with a rate limit (to delegate a whole workspace use `"resource": "workspace:"`; creation is pinned there too. A token always belongs to one account; omit `account_id` for the default): ```bash curl -X POST $BASE/api/v1/me/tokens -H "Authorization: Bearer $JWT" \ -d '{"name": "agent", "scopes": ["workbooks:read", "workbooks:write"], "resource": "workbook:", "rate_limit_per_minute": 60}' ``` 2. Connect — over MCP, Claude Code is one command (other hosts: [Connect over MCP](/en/mcp)): ```bash claude mcp add d2b --transport http https://d2b.dev/mcp/ --header "Authorization: Bearer $PAT" ``` `d2b init` writes the repo side — adds the AGENTS.md section below and prints the canonical MCP entry any host can take. Known hosts merge straight into their file with `--host claude-code|cursor|vscode|codex|windsurf|claude-desktop`; anything else via `--config PATH` (JSON or TOML). The token stays an environment-variable reference, never written to a file. For the CLI, export `D2B_API_KEY` / `D2B_BASE_URL` (Claude Code does not read .env by itself) and install with `pipx install d2b-sdk` or `uvx --from d2b-sdk d2b` (`uv add d2b-sdk` + `uv run d2b` as a project dependency). ## The repo snippet (connection and choice only) The norms come from the server, so do not copy the procedure here — a copy cannot follow new tools and goes stale. ```markdown ## D2B - Data work goes through D2B. The MCP server `d2b` is connected — follow the norms in `d2b://guide` first. - For git management (`d2b pull` / `d2b push`) and batch work use the CLI `uvx --from d2b-sdk d2b ...` (JSON output; auth from env D2B_API_KEY / D2B_BASE_URL). - Destinations, how to bring files in and how versions work are in the guide. When unsure, ask the human. ``` ## The norms an agent follows (delivered by the server) The gist of `instructions` / `d2b://guide`, with the CLI equivalent. The norms do not depend on the surface; only the tool names differ. | Norm | MCP | CLI | |---|---|---| | Orient first | `list_my_data` → `get_schema` | `d2b workbooks list` → `d2b tables list --workbook WB` | | Confirm the destination by name before creating | `list_my_workspaces` → `create_workbook` | `d2b workspaces list` → `d2b workbooks create --workspace-id …` | | Bring files in by reference (never through your own context) | `ingest_url` / `request_upload` → `ingest_upload` / `import_cloud_file` | `d2b upload FILE --workbook WB --wait` (`d2b jobs wait JOB_ID` for long runs) | | Messy sheets: inspect the structure before materialising | `ingest_file` (staged) → `analyze_source` → `update_parse_spec` → `materialize_source` | `d2b upload --mode staged` → `d2b sources analyze` → `… materialize` | | Derive with transforms, not row edits | `add_transform` / `list_transforms` | `d2b transforms add`; `d2b pull` writes them to `transforms/` | | On 409 re-read and re-apply (never overwrite blindly) | re-read edit_version with `read_table` | re-read edit_version with `d2b tables rows NAME --workbook WB` | | Cut a version at milestones so you can go back | `commit_snapshot` / `restore_snapshot` / `undo_op` | `d2b versions commit` / `d2b versions revert` | | Check numbers before reporting them | `review_table` / `review_workbook` (agent mode: follow the job with `get_job`) | `d2b review --workbook WB [--table NAME] [--agent]` | | Clean up | `delete_workbook` | `d2b workbooks delete WB` | ## Managing a workbook in git (CLI) `d2b pull --workbook $WB` writes `transforms/` `sheets/` `charts/` + `d2b.json` (`--data TABLE` adds a base table's CSV). Edit → check with `d2b push --dry-run` → `d2b push --commit $(git rev-parse --short HEAD)` → commit `d2b.json`. push sends only what changed (transforms re-run, data is 3-way merged); if refused, pull, merge, push again. `charts/` is read-only. Details: [Manage a workbook in git](/en/git-sync). ## Faithful revision (revise = fix just those rows, get the original Excel back) "Keep the whole original Excel, fix only the relevant rows, return a new file" is one `revise` call (internally it folds analyze → materialize → transform → write-back). The computation stays server-side as a SQL transform (lineage available); styles, charts, other sheets and out-of-region formulas are preserved. ```bash d2b sources revise equipment.xlsx --workbook $WB \ --transform-name merge_tokyo_sites --range A3:N8 --sheet Summary \ --sql-file merge.sql -o output.xlsx ``` `merge.sql` is a `{{ artifact_name }}` view (`{{ src }}` is bound to the region table). **How to write row-folding aggregations correctly**: - Additive columns (counts, amounts): `SUM`. - Ratios (utilisation, completion): **never average them**. Recompute after summing: `SUM(numerator) / NULLIF(SUM(denominator), 0)`. - Averages (MTBF, MTTR …): **weighted, not naive**: `SUM(value * weight) / NULLIF(SUM(weight), 0)` (weight = a meaningful column like unit count). - Categorical columns (ratings): a representative value: `arg_max(rating, units_managed)`. - **To keep row positions**, include `MIN("__d2b_row_id")` in the SELECT (the folded row lands at its first member's position; vacated rows blank out in place; nothing else moves). ```sql CREATE OR REPLACE VIEW "{{ artifact_name }}" AS SELECT CASE WHEN site IN ('Tokyo Site 1','Tokyo Site 2') THEN 'Tokyo' ELSE site END AS site, SUM(units_managed) AS units_managed, SUM(units_active) * 1.0 / NULLIF(SUM(units_managed), 0) AS utilization, SUM("MTBF(h)" * units_managed) / NULLIF(SUM(units_managed), 0) AS "MTBF(h)", arg_max(rating, units_managed) AS rating, MIN("__d2b_row_id") AS "__d2b_row_id" FROM {{ src }} GROUP BY 1 ORDER BY MIN("__d2b_row_id") ``` If the region contains total/subtotal rows (`=SUM(...)`), those formula cells are preserved untouched (Excel recalculates). Edits that would grow the region beyond the template's geometry are refused (409). Over MCP this is `revise_source`; in the SDK, `sources.revise(...)`. ## Delivering constants as live formulas (formula columns) A column of constants (say, a utilisation rate) can be delivered as a **per-row Excel formula** at export time. The values themselves are untouched — this is a delivery projection that only adds "how to re-derive it" to the shipped `.xlsx`. References use **`{column}` placeholders** (never A1 addresses — they are anchored to identity so sorting and filtering can't shift them). The server resolves coordinates at export. ```bash # Deliver utilization = units_active / units_total as a live per-row formula d2b tables set-formula equipment utilization "{units_active} / {units_total}" --workbook ``` ```python out = client.tables.set_formula(wb, "equipment", "utilization", "{units_active} / {units_total}") out["verification"] # {checked, rows, matches, mismatches} — does it reproduce the current constants? ``` Because the values are canonical, the write returns a **verification report** (does the formula reproduce the current column within tolerance?). You can confirm "replace constants with a formula" *before* delivering. Over MCP: `set_formula_column` / `list_formula_columns` / `clear_formula_column`. Currently applies to xlsx export (composable with `{{ ... }}` SQL transforms and `revise` write-back). Functions (`SUM`/`IF` …) get structural validation only; `underline` is unsupported. **Cross-sheet references**: `{table!column}` references **another table's full column range** (when several tables are exported into one file, it compiles to an absolute reference into that sheet, e.g. `'rates'!$B$2:$B$N`). Wrap it in an aggregate: ```python # Revenue share = this row's amount ÷ the sum of rates.weight client.tables.set_formula(wb, "sales", "share", "{amount} / SUM({rates!weight})") client.export.tables(wb, tables=["sales", "rates"], format="xlsx") # → each share row compiles to =B2/SUM('rates'!$B$2:$B$N) (same-table {amount} relative, cross-refs absolute) ``` If the referenced table isn't part of the same export, the formula can't resolve and **falls back to values**. Express VLOOKUP-style key joins as **JOIN transforms** (`add_transform`), not formulas (lineage is preserved). ## Constraints and notes - Cloud sandboxes (Codex etc.) may need egress allow-listing before the API is reachable - For agents with short Bash timeouts, "async upload → `jobs wait`" is safer than `upload --wait` --- # API reference Source: https://docs.d2b.dev/en/api-reference Every endpoint, generated from the OpenAPI snapshot. The interactive reference in the sidebar renders the same spec. - Auth: `Authorization: Bearer ` (/api/v1), Account API key (/api/control) - Errors: RFC7807-style problem+json (`type` / `title` / `detail` / `suggested_fix`) - Idempotency: mutations accept an `Idempotency-Key` header (same key + same body replays the first response) - Webhook signatures: `X-D2B-Signature: sha256=` = HMAC-SHA256(secret, raw_body) ## `GET /api/control/account` **Get Account** One Account (``account_id`` picks among the caller's; default = the oldest, lazily provisioned on first touch). | query | type | default | description | |---|---|---|---| | `account_id` | - | - | | ## `GET /api/control/account/billing` **Account Billing** | query | type | default | description | |---|---|---|---| | `account_id` | - | - | | ## `PATCH /api/control/account/billing/auto-topup` **Account Billing Auto Topup** Adjust (or switch off) the Account's low-balance auto-recharge. Request body: `AccountAutoTopupRequest` (see openapi.json for the schema) ## `POST /api/control/account/billing/checkout` **Account Billing Checkout** Start the Account's subscription (Solo / Pro, monthly or annual) — the same Stripe prices the personal plan page sells. Request body: `DevCheckoutRequest` (see openapi.json for the schema) ## `POST /api/control/account/billing/payg` **Account Billing Payg** Enable pay-as-you-go: a setup Checkout saves a card on the Account's customer (no charge); the webhook then flips the free tier to "payg" with auto-recharge on. $0 monthly — you pay the pack rate for what you use. Request body: `PaygEnableRequest` (see openapi.json for the schema) ## `POST /api/control/account/billing/portal` **Account Billing Portal** Request body: `DevPortalRequest` (see openapi.json for the schema) ## `POST /api/control/account/billing/reuse-card` **Account Billing Reuse Card** Reuse the card already registered on the owner's DEFAULT wallet for a developer Account (2026-09-01 onboarding: developer accounts skip the Free presentation and register a card up front). Stripe PaymentMethods can't hop between customers, so "reuse" means the developer Account SHARES the default wallet's customer. Charges/ subscriptions stay attributable: everything we create carries metadata.account_id, and lifecycle events resolve by subscription id before any customer lookup. Card on file → the account flips straight to usage billing (payg + auto-recharge), same end-state as the setup Checkout. Request body: `ReuseCardRequest` (see openapi.json for the schema) ## `POST /api/control/account/billing/topup` **Account Billing Topup** Buy a prepaid credit pack — raises the Account's metering ceiling. Request body: `DevTopupRequest` (see openapi.json for the schema) ## `POST /api/control/account/delete` **Delete Account** Delete a developer Account and everything that dies with it. Allowed only when ALL of these hold (each violation is its own error): * the caller is the owner's session (JWT) — a key must not be able to erase its own principal; * ``kind == "developer"`` — the default account IS the user's wallet; * no active/trialing subscription — cancel via the Stripe portal first (no silent cancellation side effects); * no settleable overage — postpaid liability must be billed before the debtor disappears. This counts the stored arrears bucket AND the current period's derived ops overage (which the monthly settlement would snapshot and charge), measured against the same floor settlement uses: a balance too small for settlement to ever charge is written off rather than trapping the account forever; * ``confirm`` matches the account name exactly. The cascade, in order: revoke every API key pinned to the account (cutting off concurrent access first), delete each funded workspace and its workbook rows (same metadata-delete semantics as the app's workbook deletion), then the account row. Remaining credits — period and pack — are forfeited: prepaid balances don't transfer between wallets (pack rates differ per tier; a transfer would be an arbitrage loop). The Stripe customer is never touched: it may be shared with the default wallet (reuse-card), and its invoice history stays valuable either way. Request body: `DeleteAccountRequest` (see openapi.json for the schema) ## `GET /api/control/account/keys` **List Account Keys** | query | type | default | description | |---|---|---|---| | `account_id` | - | - | | ## `POST /api/control/account/keys` **Create Account Key** Mint an account API key. The plaintext is returned exactly once. A key minted WITH a key cannot exceed the issuer's scopes, nor reach outside the issuer's own resource (no privilege escalation through re-issuance, in either dimension); the owner's JWT mints anything. ``workspace_id`` pins the key to that workspace (``resource= "workspace:"``): its workbooks on the data plane, and that workspace alone on the control plane. Request body: `CreateKeyRequest` (see openapi.json for the schema) ## `DELETE /api/control/account/keys/{key_id}` **Revoke Account Key** Revoke one of the account's API keys. Reach follows the credential: the owner's session and account-scoped keys manage every key on the account, a pinned key only keys pinned the same way — plus itself, always, so a suspect credential can burn itself without holding authority over any other. The key listing shows exactly what this reaches. | query | type | default | description | |---|---|---|---| | `account_id` | - | - | | ## `GET /api/control/account/usage/series` **Account Usage Series** Daily usage buckets for the console's trend charts. Flows (requests / ops / credits) are that day's totals; stocks (storage_bytes / tables) are the day's last sample and may be null on days no sweep landed. Days with no activity are omitted — the client fills gaps (zeros for flows, carry-forward for stocks). | query | type | default | description | |---|---|---|---| | `days` | integer | 30 | | | `account_id` | - | - | | ## `GET /api/control/accounts` **List Accounts** Every Account the caller owns (User:Account = 1:N — the paved road is one Account per service). Account keys see only their own account. 2026-09-01 unification: the DEFAULT account (the wallet the app bills) is the console's first-class account too — no lazy developer twin is minted any more. The listing is [default, ...explicit developer accounts]; ``provision`` is accepted for compatibility but inert (the default account is app-plane and materializes on first billing touch regardless of who asks). | query | type | default | description | |---|---|---|---| | `provision` | boolean | True | | ## `POST /api/control/accounts` **Create Account** Create another Account — "a new service = a new payment/contract subject". Owner JWT only: an account KEY must not mint sibling payment subjects (its blast radius is its own account). Request body: `CreateAccountRequest` (see openapi.json for the schema) ## `GET /api/control/workspaces` **List Workspaces** | query | type | default | description | |---|---|---|---| | `account_id` | - | - | | ## `POST /api/control/workspaces` **Provision Workspace** workspace.provision — create an API-managed workspace under the account. Unlike the SPA's ``POST /api/workspaces`` this does NOT move the owner's active SPA workspace (``user.workspace_id`` stays untouched): a provisioned workspace is a workspace the developer backend manages, not a switch of the human owner's working context. ``residency`` is recorded as a declaration; physical region enforcement is deferred (design doc §6). Needs an account-scoped credential (or the owner's session): a key pinned to one workspace does not create siblings for the account, the same way an account key cannot create sibling accounts. Request body: `ProvisionWorkspaceRequest` (see openapi.json for the schema) ## `GET /api/control/workspaces/{workspace_id}` **Get Workspace** ## `PATCH /api/control/workspaces/{workspace_id}` **Configure Workspace** workspace.configure — apply the provided fields, leave the rest. ``idp_config`` is stored as declared linkage only; user/role sync from the IdP is deferred until a customer IdP exists (design doc §6). Request body: `ConfigureWorkspaceRequest` (see openapi.json for the schema) ## `DELETE /api/control/workspaces/{workspace_id}` **Delete Workspace** workspace.delete — remove an EMPTY managed workspace from the account. Refused while workbooks remain (delete those first with a workbooks:delete credential) and for the built-in personal workspace. Keys pinned to the workspace are revoked as part of the deletion. ## `GET /api/control/workspaces/{workspace_id}/users` **List Workspace Users** users.list — the workspace's members with their EFFECTIVE roles (base role ∪ custom ABAC roles; the policy engine's subject). ## `PUT /api/control/workspaces/{workspace_id}/users/{user_id}/roles` **Set User Roles** Assign custom ABAC roles to a member (replaces the custom set; the base ``admin`` / ``editor`` / ``viewer`` role is membership state and stays). Built-in role names are reserved. Request body: `SetRolesRequest` (see openapi.json for the schema) ## `GET /api/control/workspaces/{workspace_id}/workbooks` **List Workspace Workbooks** Read-only observability (2026-09-01): the workbooks living in one funded workspace, for the console's data view. Visibility-filtered — the caller's own workbooks plus the ones shared with them, the same rule the app applies, so another member's private workbook is never listed. Metadata only — table listings and row previews go through the v1 data endpoints, which the owner's session already passes. ## `GET /api/v1/cloud-files/{provider}` **List Cloud Files** Importable files on the caller's linked drive. Folders are listed first for navigation (``isFolder``); pass their ``id`` as ``folder_id``. SharePoint also searches by name with ``q``. | query | type | default | description | |---|---|---|---| | `folder_id` | string | root | | | `q` | - | - | Name search (SharePoint only) | ## `GET /api/v1/jobs` **List Jobs** Recent async jobs for the calling user, newest first. Same registry as ``GET /jobs/{id}``: process-local, ~1h retention after completion — a recent-activity view, not durable history. A workbook-scoped credential sees only its workbook's jobs. ## `GET /api/v1/jobs/{job_id}` **Get Job** Status + terminal result of an async operation. ``status`` is one of ``pending | running | succeeded | failed``. ``result`` (on success) carries the same body the synchronous form would have returned; ``error`` (on failure) a short message. ## `GET /api/v1/me` **Get Me** Identity + plan summary. Mirrors the SPA's ``/api/auth/me`` but adds the credential context — an agent inspecting ``principal`` learns *which* PAT it is and what scopes it carries. Principal-aware (DX report 4 §5-1/§5-3): an account-pinned key sees ITS account's wallet and reach — ``plan``/``credits_*`` are the pinned account's buckets and ``workspace_id`` is the oldest workspace that account funds (null when it has none). The old behavior showed the owner's app-side wallet and personal-workspace pointer, which the credential could not necessarily spend from or reach. ## `GET /api/v1/me/authorize/{provider}` **Cloud Authorization** Whether D2B may read the caller's drive, and the one-page URL to authorize it when not: the user opens it in a browser once (Microsoft: connect the account; Google: connect and pick the files), then the agent retries its call. | query | type | default | description | |---|---|---|---| | `workbook_id` | - | - | | ## `GET /api/v1/me/data` **Get My Data** List every artifact this credential can see, across every workbook. Refreshes the catalog from DuckDB before serving so the result reflects the current state of each workbook's session DB. The response shape matches the proposal's §5 example: each row carries enough metadata (schema, row_count, source files, updated_at) for an agent to decide whether to drill down via ``GET /workbooks/{cid}/artifacts/{name}/...``. | query | type | default | description | |---|---|---|---| | `cursor` | - | - | Opaque cursor from a prior call. | | `limit` | integer | 50 | | ## `GET /api/v1/me/tokens` **List Access Tokens Endpoint** List the calling user's PATs. Plaintext is never present — the response is the redacted ``to_api`` form. ## `POST /api/v1/me/tokens` **Create Access Token Endpoint** Mint a new PAT. The plaintext is returned exactly once. All scopes default to ``workbooks:read`` so an accidental click in the UI can't issue a write-capable token without explicit intent. Request body: `CreateTokenRequest` (see openapi.json for the schema) ## `DELETE /api/v1/me/tokens/{token_id}` **Revoke Access Token Endpoint** Revoke a PAT. Idempotent — already-revoked tokens still return 204. Reach follows the credential: the owner's session and account-scoped keys manage every key on the account, a pinned key only keys pinned the same way — plus itself, always, so a suspect credential can burn itself without holding authority over any other. ## `GET /api/v1/me/usage` **Token Usage** Per-token daily request counts (trailing ``days``, UTC days), aggregated from the token audit log. Same management gate as the token list — usage reveals which credentials exist and how hot they run. | query | type | default | description | |---|---|---|---| | `days` | integer | 30 | | ## `GET /api/v1/me/webhooks` **List Webhooks** ## `POST /api/v1/me/webhooks` **Create Webhook** Create a webhook subscription. Returns the signing secret once. The owner is the calling user. Subscriptions are personal — there is no workspace-wide webhook in Phase 3; that lands with the wider workspace rollout in Phase 4. Request body: `CreateWebhookRequest` (see openapi.json for the schema) ## `DELETE /api/v1/me/webhooks/{webhook_id}` **Delete Webhook Endpoint** ## `GET /api/v1/me/webhooks/{webhook_id}/deliveries` **List Deliveries** Recent deliveries for one webhook. Useful for debugging "why isn't my endpoint receiving anything" — surfaces attempt count, last status, last error. ## `GET /api/v1/me/workbooks` **List My Workbooks** Paginated list of workbooks owned by the calling user. For now this is straight enumeration from the store — no workspace-shared rows, no shared-with-me rows. Those become relevant in Phase 3 when the catalog grows; today the agent's mental model is "I see the data I personally own". A ``workspace_id`` the credential cannot address is a 403, not an empty page: an empty page means "you own nothing there", and a workspace outside the credential's reach must not read the same way. | query | type | default | description | |---|---|---|---| | `cursor` | - | - | Opaque pagination cursor returned by a prior call. | | `limit` | integer | 50 | | | `workspace_id` | - | - | Scope the listing to one workspace (a workspace id, or the self-relative alias 'personal' for the caller's personal workspace) — the collection rule: collections take an explicit scope, resource-addressed calls scope themselves. | ## `GET /api/v1/me/workspaces` **List My Workspaces** The workspaces this credential can create in or read from, by name. ``is_default`` marks where ``POST /workbooks`` lands when ``workspace_id`` is omitted. Scoped to the credential's reach: an account key lists its account's workspaces the owner belongs to, a ``workspace:`` key lists that one, a ``workbook:`` key the workbook's home. ## `GET /api/v1/results/{result_id}` **Get Result** Read a materialised result by id. Supports JSON, NDJSON, Arrow. | query | type | default | description | |---|---|---|---| | `limit` | integer | 1000 | | | `offset` | integer | 0 | | ## `GET /api/v1/search` **Search** Search across the user's artifacts by name, description, column names, and source filenames. Empty / whitespace-only queries return an empty result list instead of 400 — agents driving a partially-typed search box should not see error noise on every keystroke. Real failures (FTS5 syntax issues) are filtered at the store layer. | query | type | default | description | |---|---|---|---| | `q` | string | - | Free-form keyword query. | | `limit` | integer | 20 | | ## `GET /api/v1/uploads/{upload_id}` **Get Upload Slot** Slot status: ``pending`` (waiting for bytes; ``upload_url`` is included), ``uploaded`` (ready to ingest) or ``ingested``. ## `PUT /api/v1/uploads/{upload_id}` **Put Upload Bytes** Receive the file for a slot. Authenticated by the signature in the URL alone (so a browser or a plain ``curl -T`` can send it); the body is the raw bytes. | query | type | default | description | |---|---|---|---| | `expires` | integer | - | | | `sig` | string | - | | ## `POST /api/v1/workbooks` **Create Workbook** Create a fresh workbook owned by the calling user (see :func:`create_workbook_for_principal` for the scope contract). ## `GET /api/v1/workbooks/{workbook_id}` **Get Workbook** Workbook metadata. Drops the full message history — agents that need it should pull from ``/api/workbooks/{id}`` for now (Phase 3 adds a dedicated `/messages` endpoint with windowing). ## `DELETE /api/v1/workbooks/{workbook_id}` **Delete Workbook** workbook.delete — owner-only, same semantics as the SPA's delete. Without it the CLI could create workbooks but never clean them up (DX report §5). ## `POST /api/v1/workbooks/{workbook_id}/artifacts/{name}/columns` **Add Column** Add a column to an editable table (all NULL, or ``default`` everywhere). The column lands before ``__d2b_row_id`` so the row-id keeps riding last. Type from the create-table allow-list. Request body: `AddColumnRequest` (see openapi.json for the schema) ## `PATCH /api/v1/workbooks/{workbook_id}/artifacts/{name}/columns/{column}` **Alter Column** Rename (``new_name``) OR retype (``type``) one column — exactly one per call. Retype casts existing values; a value that can't cast cleanly is a 400 (clean the data or use a SQL transform). Request body: `AlterColumnRequest` (see openapi.json for the schema) ## `DELETE /api/v1/workbooks/{workbook_id}/artifacts/{name}/columns/{column}` **Drop Column** Drop a column from an editable table. The last data column can't be dropped (delete the table instead). Any classification on the column is removed. | query | type | default | description | |---|---|---|---| | `actor` | - | - | | | `expected_version` | - | - | | ## `POST /api/v1/workbooks/{workbook_id}/artifacts/{name}/columns/{column}/tags` **Tag Column** Attach a sensitivity tag (``pii`` / ``hr`` / …) to a column. Request body: `TagColumnRequest` (see openapi.json for the schema) ## `DELETE /api/v1/workbooks/{workbook_id}/artifacts/{name}/columns/{column}/tags/{tag}` **Untag Column** Remove a column's tag (its policies stop applying to this column). | query | type | default | description | |---|---|---|---| | `actor` | string | - | | ## `GET /api/v1/workbooks/{workbook_id}/artifacts/{name}/schema` **Get Artifact Schema** Schema-only response: columns + types + row count. Carries a 5-row sample so an agent can take a quick look without triggering a paginated rows call. | query | type | default | description | |---|---|---|---| | `format` | string | columns | | ## `POST /api/v1/workbooks/{workbook_id}/artifacts/{name}/sync-bindings` **Bind Sync** Store the table ↔ external-sheet mapping (inert — D2B never executes the sync). One sheet maps to one table; binding a governed table returns explicit warnings instead of silently leaking. Request body: `BindRequest` (see openapi.json for the schema) ## `GET /api/v1/workbooks/{workbook_id}/audit-log` **Get Audit Log** The access-audit chain (newest first) + a fresh integrity check. ``chain_valid=false`` means an entry was altered, removed, or reordered after the fact. | query | type | default | description | |---|---|---|---| | `limit` | integer | 200 | | ## `GET /api/v1/workbooks/{workbook_id}/branches` **List Branches** Open branch points (one row per branched table): what an edited export can still merge back against. | query | type | default | description | |---|---|---|---| | `limit` | integer | 200 | | ## `GET /api/v1/workbooks/{workbook_id}/charts` **List Charts** Every chart in the workbook with its rendered ``config`` and the ``recipe`` (tool + params) it was generated from. Read-only: charts are re-generated from their recipe, never edited as raw config, so this is history/inspection — what ``d2b pull`` writes to ``charts/*.json``. On a governed workbook (any column policy) the ``config`` is omitted (``config_omitted="governed"``): a chart embeds the values it plots, which would bypass masking. ## `GET /api/v1/workbooks/{workbook_id}/classifications` **List Classifications** Every column tag in the workbook (the targets policies bind to). | query | type | default | description | |---|---|---|---| | `limit` | integer | 500 | | ## `GET /api/v1/workbooks/{workbook_id}/conflicts` **List Conflicts** The workbook's review queue. ``?status=open`` filters to the items still awaiting a decision. | query | type | default | description | |---|---|---|---| | `status` | - | - | | | `limit` | integer | 200 | | ## `POST /api/v1/workbooks/{workbook_id}/conflicts/{conflict_id}/resolve` **Resolve Conflict** Close a review-queue item. ``acknowledge`` records the decision; ``revert`` (overwrite conflicts) writes the prior value back as a fresh attributed edit; ``drop_override`` (stale_override conflicts) removes the override so the recomputed value shows. Request body: `ResolveConflictRequest` (see openapi.json for the schema) ## `POST /api/v1/workbooks/{workbook_id}/export` **Export Workbook** Deliver tables as a file (xlsx: one sheet per table; csv: one file, or a zip for several tables). ``formula_mode="preserve"`` (xlsx only) restores formula-provenance columns as live formulas translated to the file's coordinates — the ``X-D2B-Preserved-Formulas`` header says which columns recalculate. ``generate`` needs the reference-map layer and is still refused explicitly. ``include_row_ids=True`` carries ``__d2b_row_id`` along (hidden column in xlsx) so an edited file can be merged back by row identity later; ``record_branch=True`` additionally freezes the exported rows as the merge base (export = branch). Masking/deny policies apply to the produced bytes; the ``X-D2B-Masked-Columns`` / ``X-D2B-Denied-Columns`` headers say what was enforced. Request body: `ExportRequest` (see openapi.json for the schema) ## `GET /api/v1/workbooks/{workbook_id}/file-links` **List File Links** The workbook's tracked OneDrive / SharePoint files with their versions (newest last): ``n``, ``kind`` (link / sync / revert), ``modified_by``, ``modified``, ``synced_at``, ``snapshot_id``. ## `POST /api/v1/workbooks/{workbook_id}/file-links` **Track Cloud File** Put an xlsx on OneDrive / SharePoint under D2B version control: its current bytes become version 1 and feed a workbook source of the same name; every later save becomes a version (see ``GET .../file-links``). Request body: `TrackFileRequest` (see openapi.json for the schema) ## `POST /api/v1/workbooks/{workbook_id}/file-links/local` **Track Local File** Track a file that lives on the user's machine (``d2b watch``): the uploaded bytes become version 1 and feed a source of the same name; the client pushes later changes to ``…/file-links/{id}/versions``. No drive API, no polling — the machine is the detector. If the workbook already holds a source of that name: ``on_existing=auto`` keeps D2B's copy as v1 when it looks like the same file (a sheet in common) and refuses otherwise; ``as_name`` tracks under another name; ``replace`` overwrites D2B's copy; ``seed`` forces the v1 seed. ## `POST /api/v1/workbooks/{workbook_id}/file-links/{link_id}/sync` **Sync File Link** Pull the drive's copy now (even when its eTag did not move). A changed file becomes a new version and re-ingests the source. Requires the ``workbooks:write`` and ``cloud-files:read`` scopes and edit rights on the workbook. ## `POST /api/v1/workbooks/{workbook_id}/file-links/{link_id}/versions` **Push File Link Version** Push the file's current bytes as the next version (``d2b watch`` does this on every change). Identical bytes cut no version (``changed: false``). ## `GET /api/v1/workbooks/{workbook_id}/file-links/{link_id}/versions/{n}/cells` **File Link Version Cells** A version's sheet as ``{cell, value}`` pairs (values only) — what an agent needs to write that version back into the spreadsheet itself. Refused on governed workbooks because raw cell values cannot enforce column policies. | query | type | default | description | |---|---|---|---| | `sheet` | - | - | | ## `GET /api/v1/workbooks/{workbook_id}/file-links/{link_id}/versions/{n}/diff` **Diff File Link Version** Cell-by-cell diff from ``against`` (default: the previous version) to ``n``: per sheet, ``{cell, old, new}`` (values only, capped). Refused on governed workbooks because raw values cannot enforce column policies. | query | type | default | description | |---|---|---|---| | `against` | - | - | | ## `GET /api/v1/workbooks/{workbook_id}/file-links/{link_id}/versions/{n}/download` **Download File Link Version** The version's original xlsx bytes. Refused on governed workbooks because raw files cannot enforce column policies. ## `POST /api/v1/workbooks/{workbook_id}/file-links/{link_id}/versions/{n}/revert` **Revert File Link Version** Re-ingest version ``n`` on D2B's side as a new version. The file on the drive is untouched — use ``cells`` (or ``download``) to put the values back into the spreadsheet yourself. ## `GET /api/v1/workbooks/{workbook_id}/ops` **List Ops** The workbook's ordered mutation history, newest first. Each op carries ``kind`` (rows.upsert / transform.run / formula.set / version.commit / rows.undo …), ``actor`` attribution, a replayable ``code`` reference, and ``undoable`` (true when this surface can apply its inverse via the undo endpoint). | query | type | default | description | |---|---|---|---| | `limit` | integer | 50 | | | `before` | - | - | Page: only ops with op_id < before. | ## `POST /api/v1/workbooks/{workbook_id}/ops/{op_id}/undo` **Undo Op** Undo one row op (rows.upsert / rows.delete) by applying its inverse. The undo is itself a normal write: it bumps the table version, lands in the edit log with attribution, marks downstream artifacts stale (auto-recompute settles them) and records a ``rows.undo`` op. An op can be undone once; undoing the undo is just another undo, targeting the ``rows.undo`` op's own recorded edits. ## `GET /api/v1/workbooks/{workbook_id}/policies` **List Policies** The workbook's tag→effect rules. | query | type | default | description | |---|---|---|---| | `limit` | integer | 500 | | ## `PUT /api/v1/workbooks/{workbook_id}/policies/{tag}` **Set Policy** Upsert the rule for ``(tag, role)``: ``mask`` (typed NULL + metadata), ``deny`` (column disappears), or ``allow`` (a role's explicit exemption over the ``role="*"`` default). Request body: `SetPolicyRequest` (see openapi.json for the schema) ## `DELETE /api/v1/workbooks/{workbook_id}/policies/{tag}` **Delete Policy** Drop a tag's rules (the tag itself stays on the columns). ``?role=`` removes just that role's row; without it the tag stops being governed entirely. | query | type | default | description | |---|---|---|---| | `actor` | string | - | | | `role` | - | - | | ## `POST /api/v1/workbooks/{workbook_id}/query` **Post Query** Run a read-only SELECT and materialise the result. Returns a result-set manifest plus the first page in the requested format. The manifest's ``id`` is the durable handle; subsequent ``GET /api/v1/results/{id}`` calls read pages off the materialised Parquet snapshot without re-running the query. Request body: `QueryRequest` (see openapi.json for the schema) ## `POST /api/v1/workbooks/{workbook_id}/query/validate` **Validate Query** Static check + schema dry-run for a SELECT, no execution. Returns ``{ valid, errors, columns }``. Use this when an agent wants to surface "this query won't run" before committing to /query — the feedback loop is fast (no Parquet write, no result_id) and the column list it returns is exact (it goes through DuckDB's planner, so a typo'd column name is caught here). Request body: `ValidateRequest` (see openapi.json for the schema) ## `POST /api/v1/workbooks/{workbook_id}/recompute` **Recompute Stale** Re-run the transforms behind every stale table/view, dependency order, and clear their staleness. Interactive writes (row edits, override drops, merges) mark their downstream artifacts stale instead of eagerly re-running them; this endpoint settles the whole backlog in one batch. Stale REPORTS are returned in ``skipped_reports`` — their regeneration is an LLM call the app's regenerate flow owns. Artifacts whose transform fails stay stale and are listed in ``failed``. ## `POST /api/v1/workbooks/{workbook_id}/review` **Review** Review a table (``target``) or the whole workbook and return evidence-backed findings. Read-only. Checks: the source file's formulas recomputed from its cells (overwritten values, broken fill-downs, ranges that stop short, subtotals summed twice); invariants of each table (mixed types or spellings in a column, gaps and repeats in an ordered key, values the input never had, totals a plain projection changed, rows that equal the sum of the rows above them); and, with ``judge``, whether each computed column's label matches its computation. Every finding carries the SQL or the cell that found it. ``coverage`` says what was and was not examined. ``mode=lint`` answers ``200`` with the review when it finishes within about 45 seconds, otherwise ``202`` with a job. ``mode=agent`` (or ``async=true``) answers ``202`` at once. The job's ``result`` is the same body. The judgement model and the agent spend the workbook's credits (``credits``); out of credits, lint runs without the judgement model and the agent is refused (402). Refused on governed workbooks (403). Request body: `ReviewRequest` (see openapi.json for the schema) ## `GET /api/v1/workbooks/{workbook_id}/sheets` **List Sheets** ## `GET /api/v1/workbooks/{workbook_id}/sheets/{name}` **Get Sheet** ## `PUT /api/v1/workbooks/{workbook_id}/sheets/{name}` **Put Sheet** Create or replace a sheet composition. ``spec.blocks`` is an ordered vertical flow of ``{kind: heading|text|table_view|spacer, ...}``; ``table_view`` blocks REFERENCE tables by name (n:m — the same table can sit on many sheets, and deleting a sheet deletes no data). Request body: `PutSheetRequest` (see openapi.json for the schema) ## `DELETE /api/v1/workbooks/{workbook_id}/sheets/{name}` **Delete Sheet** ## `GET /api/v1/workbooks/{workbook_id}/sheets/{name}/render` **Render Sheet** Compose the sheet into an xlsx. Table blocks read through the governance projection — masked columns deliver as blanks, denied columns are absent — exactly like the rows surface. ## `GET /api/v1/workbooks/{workbook_id}/snapshots` **List Snapshots** List committed snapshots for the workbook. ## `POST /api/v1/workbooks/{workbook_id}/snapshots` **Commit Snapshot** Commit the current state as an immutable snapshot. Subsequent edits land on a fresh editing snapshot; the snapshot just committed becomes the rollback target for ``POST .../snapshots/{id}/restore``. Request body: `CommitSnapshotRequest` (see openapi.json for the schema) ## `POST /api/v1/workbooks/{workbook_id}/snapshots/{snapshot_id}/restore` **Restore Snapshot** Roll back to a prior snapshot. Destructive — requires workbooks:delete. ## `GET /api/v1/workbooks/{workbook_id}/sources` **List Sources** The workbook's sources with their lifecycle status. ## `POST /api/v1/workbooks/{workbook_id}/sources` **Post Source** Upload a file into the workbook. ``mode=auto`` (default) runs the one-shot extraction pipeline and the response carries the created source + every artifact it produced. ``mode=staged`` only lands the bytes (zero interpretation, status ``registered``) — follow with ``POST .../sources/{name}/analyze``, review/correct the parse spec, then ``POST .../sources/{name}/materialize``. ``async=true`` (auto mode only): the pipeline includes the structuring pass, so for big files the synchronous form can hold the socket for minutes. The async form returns ``202`` with a ``job_id`` immediately; poll ``GET /api/v1/jobs/{job_id}`` for the terminal result (the data events — ``source.materialized``, ``artifact.updated`` — still arrive on webhooks as usual). Job state is process-local with a 1h retention; a deploy mid-job loses the handle but never the workbook's durable state. Idempotency: pass ``Idempotency-Key: `` to make a retry safe — the cached response is returned for 24 h (for async calls the same ``job_id`` is replayed). Even without the header, identical bytes in the same workbook are de-duplicated: we return the existing source rather than re-ingesting. ## `POST /api/v1/workbooks/{workbook_id}/sources/cloud` **Import Cloud File** Download a file from the caller's Google Drive or OneDrive / SharePoint into the workbook and run extraction — the same ingest as a direct upload, with the file's provenance stamped on the source so it can be refreshed later. Request body: `CloudImportRequest` (see openapi.json for the schema) ## `POST /api/v1/workbooks/{workbook_id}/sources/from-url` **Ingest From Url** Fetch a public URL server-side and ingest it (same response as ``POST /workbooks/{id}/sources``). Private networks, localhost and redirects are refused; the size cap applies while streaming. Request body: `UrlIngestRequest` (see openapi.json for the schema) ## `GET /api/v1/workbooks/{workbook_id}/sources/{source_name}` **Get Source** One source + a summary of its parse spec (when analysed). ## `POST /api/v1/workbooks/{workbook_id}/sources/{source_name}/analyze` **Analyze Source** Run structure detection and persist the ParseSpec proposal. Pure inference — nothing is materialised. Re-running bumps the spec version and discards prior edits. Fires ``source.analyzed``. ## `GET /api/v1/workbooks/{workbook_id}/sources/{source_name}/download` **Download Source** The source's ORIGINAL uploaded bytes, byte-identical (v0.5 §5.2 L0). This is the honest answer to "give me the original file back": no reconstruction, the file itself. Refused on governed workbooks for the same reason as preview — the original bytes bypass column policies. ## `POST /api/v1/workbooks/{workbook_id}/sources/{source_name}/materialize` **Materialize Source** Faithfully load the selected regions into tables. Default selection is every ``kind="data"`` region. ``target.mode="new"`` creates one table per region; ``"append"`` accumulates rows into the existing ``target.table`` (column-matched by name via ``target.column_mapping``). Fires ``source.materialized`` + ``artifact.updated`` per produced artifact. ## `GET /api/v1/workbooks/{workbook_id}/sources/{source_name}/parse-spec` **Get Parse Spec** The current ParseSpec document (regions + reader parameters). ## `PUT /api/v1/workbooks/{workbook_id}/sources/{source_name}/parse-spec` **Put Parse Spec** Correct the proposal: split/merge/exclude regions, fix ranges, kinds, or header flags. Bumps the spec version (status ``edited``). Request body: `UpdateParseSpecRequest` (see openapi.json for the schema) ## `GET /api/v1/workbooks/{workbook_id}/sources/{source_name}/preview` **Preview Source** A bounded look at the source's RAW BYTES (spec §5 files.preview): the first rows of each sheet, before any analysis or materialisation — so an agent can decide what to do with a file it just landed (staged sources preview straight from ``registered``). Refused on governed workbooks: the original bytes would bypass column policies, so once any policy exists, reads go through the policy-applied rows/schema/profile surfaces instead. | query | type | default | description | |---|---|---|---| | `rows` | integer | 20 | | ## `GET /api/v1/workbooks/{workbook_id}/sources/{source_name}/render` **Render Source Template** Template write-back (v0.5 §5.2 L1): the source's ORIGINAL xlsx with its data-region cell values replaced by the current tables. Styles, merges, column widths, charts, images, macros and every formula OUTSIDE the data regions survive byte-identical — the original file is the style store; only the linked regions' values change. xlsx + staged-materialised (region-linked) sources only; a table that grew past its template region is refused (409). ``overrides`` (JSON ``{"": ""}``) writes a derived/transform table into a region in place of its linked source table — faithful output with the computation kept server-side and lineage-traceable. The replacement must carry the region's columns by name. Refused on governed workbooks: the template's non-data cells are original bytes, which bypass column policies. | query | type | default | description | |---|---|---|---| | `overrides` | - | - | Optional JSON object mapping a region-linked table name → a replacement (e.g. transform) table name. That region is written from the replacement instead of the linked source table, so a SQL transform's result renders into the template. | ## `POST /api/v1/workbooks/{workbook_id}/sources/{source_name}/revise` **Revise Source Endpoint** Faithful edit in one call (v0.5 §5.2 L1): analyze + materialize + transform + template write-back, folded. Applies ``transform`` (a ``{{ artifact_name }}`` SQL template with ``{{ src }}`` bound to the target region's table) to a region of the source, then returns the ORIGINAL xlsx with only that region's values replaced — styles/charts/other sheets byte-identical. The transform persists (its DAG is queryable); positional write-back and in-region formula protection apply. Carry ``MIN("__d2b_row_id")`` in the SELECT to keep row positions on a folding merge. Region: ``region.region_id`` (existing), ``region.sheet``+``range`` (reuse-or-add the rectangle — no separate update-parse-spec call), or omit for the source's single data region. Refused on governed workbooks (the template's non-data cells bypass column policies). Request body: `ReviseRequest` (see openapi.json for the schema) ## `POST /api/v1/workbooks/{workbook_id}/sync` **Sync Returned File** One-call sync of a returned export (design pillar 3, Tier 0 — the "agent mails a workbook, the end user edits it in Excel, the file comes back" loop). Merges EVERY branched table whose sheet the file still carries, via the same per-table 3-way arbitration as ``.../merge`` (clean changes apply as attributed edits, both-sides changes queue ``kind="merge"`` conflicts). ``branch_id`` defaults to the newest recorded branch. Tables whose sheet is missing are reported in ``skipped``; a sheet that fails to parse lands in ``errors`` without aborting the rest. Records one ``source.sync`` op summarizing the whole exchange. ## `GET /api/v1/workbooks/{workbook_id}/sync-bindings` **List Sync Bindings** The workbook's stored bindings (optionally one artifact's). | query | type | default | description | |---|---|---|---| | `artifact` | - | - | | | `limit` | integer | 200 | | ## `DELETE /api/v1/workbooks/{workbook_id}/sync-bindings/{binding_id}` **Unbind Sync** Remove a stored binding (the external sheet itself is untouched). | query | type | default | description | |---|---|---|---| | `actor` | string | - | | ## `GET /api/v1/workbooks/{workbook_id}/sync-bindings/{binding_id}/snapshot` **Binding Snapshot** The diff baseline for the agent's sync cycle: governance-applied rows (row-id keyed when present) captured together with the table's edit version. | query | type | default | description | |---|---|---|---| | `limit` | integer | 10000 | | ## `GET /api/v1/workbooks/{workbook_id}/tables` **List Artifacts** List artifacts (tables + views) for one workbook, refreshing the catalog first so the response reflects DuckDB state. Archived artifacts — raw shapes the structured tier superseded at ingest (promotion model) — are hidden by default; every row carries ``state``. ``include_archived=true`` appends them (``state: "archived"``, no schema/row_count — their table is not materialised) so an agent can see what ``unarchive`` would bring back. | query | type | default | description | |---|---|---|---| | `include_archived` | boolean | False | | ## `POST /api/v1/workbooks/{workbook_id}/tables` **Create Table** Create an empty table from a schema — no source file required. The table registers as an editable raw artifact with row-ids assigned and ``edit_version`` seeded at 1, so rows can be upserted immediately (``expected_version: 1``). Emits ``artifact.updated``. Request body: `CreateTableRequest` (see openapi.json for the schema) ## `GET /api/v1/workbooks/{workbook_id}/tables/{name}` **Get Artifact** Single-artifact metadata. Returns schema, row_count, source files, and ``freshness`` — whether the artifact still reflects its inputs (design §9: staleness is a first-class, introspectable state). ## `PATCH /api/v1/workbooks/{workbook_id}/tables/{name}` **Rename Table** Rename an artifact's display name (id-identity refactor P4): the immutable id and the physical table are untouched, so downstream transforms follow via the binding ids and reads keep working under the new name. Refuses a taken name (409) and, for now, a python-transform output. Request body: `RenameTableRequest` (see openapi.json for the schema) ## `DELETE /api/v1/workbooks/{workbook_id}/tables/{name}` **Delete Artifact** Drop an artifact (table or view). Destructive — requires workbooks:delete. ## `GET /api/v1/workbooks/{workbook_id}/tables/{name}/a1` **Read A1** A1 read facade (v0.5 §7): the coordinate system agents know best. The table renders as a grid — row 1 is the header (column names), data starts at row 2, columns A.. follow the (policy-applied) column order. ``range`` is ``"B2:D10"`` or a single cell ``"C3"``. Returns ``{"range", "values": [[...]]}`` — values only, like the Sheets API. Reads ride the same governance projection as rows. | query | type | default | description | |---|---|---|---| | `range` | string | - | | ## `PUT /api/v1/workbooks/{workbook_id}/tables/{name}/a1` **Write A1** A1 write facade: set a rectangle of cells by spreadsheet coordinates (the partner to GET .../a1). Grid row 1 is the header; data starts at row 2; columns A.. follow the data-column order. ``values`` is a row-major 2-D block matching the ``range`` shape. Grid rows that hit existing data rows update; rows past the bottom append (contiguous only). Maps onto row edits — same optimistic locking (``expected_version``), edit log, and conflict detection as rows.upsert. Writing the header row, a column past the table width, or leaving a gap is a 400. Request body: `A1WriteRequest` (see openapi.json for the schema) ## `GET /api/v1/workbooks/{workbook_id}/tables/{name}/access` **Check Access** Dry-run (spec `access.check`): what would a read of this artifact return, per column — allow / mask / deny — without reading anything. Honors ``X-D2B-Acting-User``, so a backend can preview a member's effective access before running queries on their behalf. ## `POST /api/v1/workbooks/{workbook_id}/tables/{name}/columns` **Add Column** Add a column to an editable table (all NULL, or ``default`` everywhere). The column lands before ``__d2b_row_id`` so the row-id keeps riding last. Type from the create-table allow-list. Request body: `AddColumnRequest` (see openapi.json for the schema) ## `PATCH /api/v1/workbooks/{workbook_id}/tables/{name}/columns/{column}` **Alter Column** Rename (``new_name``) OR retype (``type``) one column — exactly one per call. Retype casts existing values; a value that can't cast cleanly is a 400 (clean the data or use a SQL transform). Request body: `AlterColumnRequest` (see openapi.json for the schema) ## `DELETE /api/v1/workbooks/{workbook_id}/tables/{name}/columns/{column}` **Drop Column** Drop a column from an editable table. The last data column can't be dropped (delete the table instead). Any classification on the column is removed. | query | type | default | description | |---|---|---|---| | `actor` | - | - | | | `expected_version` | - | - | | ## `PUT /api/v1/workbooks/{workbook_id}/tables/{name}/columns/{column}/description` **Set Column Description** Set or clear the description for one column on an artifact. The column must exist in the artifact's schema (we check the schema dictionary stored on the catalog row); a misspelled column name surfaces as 404 rather than silently creating a phantom description that never lights up in get_schema. Request body: `ColumnDescriptionBody` (see openapi.json for the schema) ## `PUT /api/v1/workbooks/{workbook_id}/tables/{name}/columns/{column}/formula` **Set Formula** Deliver ``column`` as a per-row formula. ``expr`` references columns as ``{column_name}`` placeholders (never A1 cell addresses) — e.g. ``"{running} / {count}"``. The stored value is untouched; the formula is applied when the table is exported to xlsx. The response carries a ``verification`` report — whether the formula reproduces the column's current values within tolerance — so "replace this constant with a calc" can be confirmed, not hoped. Request body: `SetFormulaRequest` (see openapi.json for the schema) ## `DELETE /api/v1/workbooks/{workbook_id}/tables/{name}/columns/{column}/formula` **Clear Formula** Drop ``column``'s formula — it delivers its stored value again. ## `POST /api/v1/workbooks/{workbook_id}/tables/{name}/columns/{column}/tags` **Tag Column** Attach a sensitivity tag (``pii`` / ``hr`` / …) to a column. Request body: `TagColumnRequest` (see openapi.json for the schema) ## `DELETE /api/v1/workbooks/{workbook_id}/tables/{name}/columns/{column}/tags/{tag}` **Untag Column** Remove a column's tag (its policies stop applying to this column). | query | type | default | description | |---|---|---|---| | `actor` | string | - | | ## `PUT /api/v1/workbooks/{workbook_id}/tables/{name}/description` **Set Artifact Description** Set or clear the description for one artifact. The catalog must already contain a row for this artifact — i.e. the artifact was registered via a prior ingest or transform. We refresh the catalog on entry so a brand-new artifact created seconds ago is reachable. Request body: `ArtifactDescriptionBody` (see openapi.json for the schema) ## `GET /api/v1/workbooks/{workbook_id}/tables/{name}/formulas` **List Formulas** The table's formula columns: ``{column: expr}``. ## `GET /api/v1/workbooks/{workbook_id}/tables/{name}/lineage` **Get Artifact Lineage** Upstream DAG for one artifact (sources + transform-derived intermediates). ## `POST /api/v1/workbooks/{workbook_id}/tables/{name}/merge` **Merge Export** 3-way merge an edited export back into its table. Cells changed only in the file apply as attributed edits; cells changed on both sides since the branch queue ``kind="merge"`` conflicts (D2B's value stays until someone resolves — ``take_import`` applies the file's value, ``acknowledge`` keeps ours). Rows the file deleted delete here when D2B left them untouched; rows D2B deleted are never silently resurrected. ## `GET /api/v1/workbooks/{workbook_id}/tables/{name}/overrides` **List Overrides** The artifact's override layer: which cells are shadowed, by what value, over which computed value, by whom and when (doc §6 — the override story is always inspectable). | query | type | default | description | |---|---|---|---| | `limit` | integer | 500 | | ## `POST /api/v1/workbooks/{workbook_id}/tables/{name}/overrides/delete` **Drop Override** Remove one cell's override and restore the computed value it shadowed. The explicit exception ends explicitly. Request body: `DropOverrideRequest` (see openapi.json for the schema) ## `GET /api/v1/workbooks/{workbook_id}/tables/{name}/profile` **Get Artifact Profile** SUMMARIZE-style statistics for one table (spec §5 tables.profile): per-column null rate, distinct count, numeric ranges, top values and normalisation signals — the deep look ``schema``'s 5-row sample can't give. Governance applies: denied columns are absent, masked columns carry no statistics (their distribution is what the mask hides), and an enforced read is audited. ## `GET /api/v1/workbooks/{workbook_id}/tables/{name}/rows` **Get Artifact Rows** Paginated rows in JSON shape: ``{columns: [...], rows: [[...]]}``. The row container is a list-of-lists (column-aligned) rather than list-of-dicts, so per-row payload size stays close to Arrow's columnar footprint. Arrow + NDJSON content negotiation lands in Slice 3 (proposal §5 row payloads). | query | type | default | description | |---|---|---|---| | `limit` | integer | 100 | | | `offset` | integer | 0 | | ## `POST /api/v1/workbooks/{workbook_id}/tables/{name}/rows` **Upsert Rows** Bulk upsert rows of a base table. A row carrying ``__d2b_row_id`` updates that row; a row without it inserts (missing columns become NULL, the new ``__d2b_row_id`` comes back in ``row_ids``). Atomic: one bad row rolls back the whole call. ``expected_version`` must match the table's current edit version (pass ``null`` for a never-edited table); stale writes get 409 with the current version. Each change lands in the edit log with ``actor`` attribution. Request body: `UpsertRowsRequest` (see openapi.json for the schema) ## `POST /api/v1/workbooks/{workbook_id}/tables/{name}/rows/delete` **Delete Rows** Bulk delete rows by ``__d2b_row_id``. Atomic: an unknown row-id aborts the whole call. Each deleted row's full content is recorded in the edit log before removal, so the delete is logical-in-effect (nothing is lost) while reads stay clean. Same optimistic locking as upsert. Request body: `DeleteRowsRequest` (see openapi.json for the schema) ## `GET /api/v1/workbooks/{workbook_id}/tables/{name}/schema` **Get Artifact Schema** Schema-only response: columns + types + row count. Carries a 5-row sample so an agent can take a quick look without triggering a paginated rows call. | query | type | default | description | |---|---|---|---| | `format` | string | columns | | ## `GET /api/v1/workbooks/{workbook_id}/tables/{name}/style` **Get Style** Current spec + annotations for one table. ## `PUT /api/v1/workbooks/{workbook_id}/tables/{name}/style` **Put Style** Replace the table's style spec. ``spec`` = ``{header?, columns?, rules?, column_widths?, freeze_header?, borders?}``; styles are ``{bold, italic, underline, color, fill, number_format, align}``. Rules carry a SQL predicate (``"amount < 0"``) re-evaluated at delivery — the right tool for bulk styling ("negatives in red"), since one rule never drifts and never costs 10k annotations. Request body: `PutStyleRequest` (see openapi.json for the schema) ## `POST /api/v1/workbooks/{workbook_id}/tables/{name}/style/annotations` **Annotate** Mark specific cells: ``{annotations: [{row_id, column, style}]}``. Anchored to ``__d2b_row_id`` × column — the same identity anchoring as value edits, so the mark follows the row through sorts and inserts ("THIS row", not "the 5th row"). For bulk conditions use a spec rule instead. Request body: `AnnotateRequest` (see openapi.json for the schema) ## `POST /api/v1/workbooks/{workbook_id}/tables/{name}/style/annotations/delete` **Delete Annotations** Request body: `DeleteAnnotationsRequest` (see openapi.json for the schema) ## `POST /api/v1/workbooks/{workbook_id}/tables/{name}/sync-bindings` **Bind Sync** Store the table ↔ external-sheet mapping (inert — D2B never executes the sync). One sheet maps to one table; binding a governed table returns explicit warnings instead of silently leaking. Request body: `BindRequest` (see openapi.json for the schema) ## `POST /api/v1/workbooks/{workbook_id}/tables/{name}/unarchive` **Unarchive Artifact** Re-materialise an archived raw artifact from the shared cache (D3, v0.5 §4): the registry row and lineage never left; this brings the physical table back and flips state to active. Use it when an agent needs to descend from a structured table to the raw shape it was derived from (follow get_lineage to find the name). ## `GET /api/v1/workbooks/{workbook_id}/transforms` **List Transforms** Every authored transform that currently produces an artifact, with its template — the read half of ``POST /transforms``. One entry per transform → artifact edge (a template applied to two outputs appears twice, once per ``artifact_name``). ``args`` carry the CURRENT display names of the bound inputs, so an entry round-trips through ``POST /transforms`` unchanged even after a rename. Built-in source extractors (``auto_extractors/*``) are not listed — they are not authored logic. Templates are code, not rows: column policies do not apply here, exactly as in the app's Transform view. This is what ``d2b pull`` materialises as ``transforms/*.sql|py`` files, so a workbook's analytical logic can live in the caller's own git repository and come back through ``d2b push``. ## `POST /api/v1/workbooks/{workbook_id}/transforms` **Post Transform** Register a SQL or Python transform and materialise its output. The transform runs synchronously; the response carries the resulting artifact metadata. Mirror of the SPA's chat-agent path, so agents and humans co-author the same DAG. Request body: `TransformRequest` (see openapi.json for the schema) ## `POST /api/v1/workbooks/{workbook_id}/uploads` **Create Upload Slot** Open an upload slot: the bytes go to ``upload_url`` (a signed PUT, no bearer needed, valid one hour) — from your own HTTP client, or by a human through ``upload_page`` — and ``POST .../uploads/{id}/ingest`` turns them into a source. Nothing about the file passes through the caller, which is the point for agents. Request body: `UploadSlotRequest` (see openapi.json for the schema) ## `POST /api/v1/workbooks/{workbook_id}/uploads/{upload_id}/ingest` **Ingest Upload Slot** Turn a received upload into a source (same response as ``POST /workbooks/{id}/sources``, plus ``upload_id``). ## `GET /api/v1/workbooks/{workbook_id}/versions` **List Versions** ## `POST /api/v1/workbooks/{workbook_id}/versions` **Create Version** Commit the current state as a named version (label → snapshot). Labels are immutable; committing an existing label is a 409. Request body: `CreateVersionRequest` (see openapi.json for the schema) ## `POST /api/v1/workbooks/{workbook_id}/versions/{label}/revert` **Revert To Version** Restore the workbook to the named version's snapshot — the same destructive machinery (and the same events) as snapshot restore. --- # CLI Source: https://docs.d2b.dev/en/cli The d2b command — JSON on stdout, suggested_fix-bearing errors on stderr. One contract for humans and shell-driving agents. `pip install d2b-sdk` ([PyPI](https://pypi.org/project/d2b-sdk/), source on [GitHub](https://github.com/600/d2b-sdk)) also installs the `d2b` command (under pyenv, prefer `pipx install d2b-sdk` / `uvx --from d2b-sdk d2b` — it avoids shim-resolution accidents). Output is JSON (stdout); errors carry the `suggested_fix` on stderr; exit codes are 0 / 1 (API errors, refused syncs) / 2 (usage). Nothing prompts interactively. ## Authentication Browser login is the default (no raw API keys to handle): ```bash d2b login # defaults to https://d2b.dev (--base-url / $D2B_BASE_URL for another deployment) # → a confirmation code and URL appear and the browser opens. Check the code on # screen matches the terminal, tick the accounts this CLI may act for, then # approve. One token is issued per account and stored at # ~/.config/d2b/credentials.json (0600). # CLI tokens live 90 days — just `d2b login` again when they expire. d2b login --scopes workbooks:read,workbooks:write # Default is the whole workbooks family — read + write + delete — so the CLI # can delete the workbooks it creates; pass --scopes only to narrow (e.g. read-only). d2b whoami # includes account_id / account_name / workspace_name d2b workspaces list # the workspaces this credential reaches, by name (is_default = where creates land) d2b --account acc-… whoami # switch accounts when several were approved ($D2B_ACCOUNT_ID works too) d2b logout # forgets the saved login and revokes every server-side token ``` A token is always bound to exactly one account (one token = one account). The approval page pre-selects your default account; each developer account you add gets its own token. Without `--account` the default account's token is used. A login token reaches `resource=account`: the whole account it is bound to — for the default account, your personal workspace plus the team workspaces you are an active member of; for a developer account, every workspace that account funds. `d2b workbooks create --workspace-id …` can create in any of them, and the response's `workspace_name` says where it landed. Reach is set by that pin alone; scopes say what the token may do (`workspaces:read` is the control plane's permission to read workspace settings and does not change reach). Naming a workspace the token does not reach in `--workspace-id` is a 403 for listing and creation alike (`d2b workspaces list` shows the reach). When you need a credential confined to one workspace, mint a key with `resource: "workspace:"` in the console or via `POST /api/v1/me/tokens` and use it through `D2B_API_KEY`. Non-interactive environments (CI, agents) use environment variables (precedence: flags > env > saved login). **Never pass API keys as command-line arguments** (`--api-key` is rejected, and secret-shaped strings are redacted from parser errors). ```bash export D2B_API_KEY=d2b_pat_... D2B_BASE_URL=https://d2b.dev ``` ## The main commands ```bash d2b workbooks create --title monthly # → {"id": "..."} d2b workbooks list d2b upload sales.xlsx --workbook WB --wait # async ingest + job wait d2b tables list --workbook WB d2b tables schema sales --workbook WB --json-schema d2b tables rows sales --workbook WB --limit 50 d2b tables a1 sales A1:D10 --workbook WB # read in Excel coordinates d2b tables write-a1 sales B2:C3 '[[10],[20]]' --workbook WB --expected-version 12 d2b tables add-column sales with_tax --type DOUBLE --workbook WB d2b tables set-formula equipment utilization "{units_active} / {units_total}" --workbook WB d2b query 'SELECT count(*) FROM "sales"' --workbook WB d2b review --workbook WB --table sales # evidence-backed findings (--agent: verified, with a summary) d2b export --workbook WB --format xlsx -o out.xlsx d2b sources render report.xlsx --workbook WB -o monthly.xlsx # original formatting d2b sources revise equipment.xlsx --workbook WB --transform-name merge_sites --range A3:N8 --sql-file merge.sql -o out.xlsx d2b sheets list --workbook WB d2b sheets put report --spec sheet.json --workbook WB # blocks: heading / text / table_view / spacer d2b sheets render report --workbook WB -o report.xlsx d2b transforms list --workbook WB d2b charts list --workbook WB d2b versions commit 2026-06 --workbook WB d2b versions revert 2026-06 --workbook WB d2b jobs wait JOB_ID --timeout 1800 ``` ## The git round trip `d2b pull` / `d2b push` / `d2b github-workflow` — see [Your workbook in git](/en/git-sync). ## Notes for agent use - For agents with short Bash timeouts, "async upload → `jobs wait`" is safer than `upload --wait` - On a 409 (ConflictError): re-read `edit_version` with `d2b tables rows NAME --workbook $WB`, re-apply, retry (never overwrite blindly) - The snippet to paste into a repository lives in [Using D2B from coding agents](/en/agents) ## `--wait` and the API's `async=true` | CLI | API | Behavior | |---|---|---| | `d2b upload FILE --wait` | `POST .../sources?async=true` + job polling | accepted with 202, waits for completion (auto mode only) | | `d2b upload FILE` (no `--wait`) | same, without polling | returns a `job_id` — wait with `d2b jobs wait JOB_ID` | | `--mode staged` | no `async` (always synchronous) | bytes land immediately; `--wait` is unnecessary (and a usage error) | When an error message says `async`, it means the API parameter — on the CLI that's `--wait` (such errors also ship `suggested_fix_cli` in CLI vocabulary, which the CLI prints). ## Install guidance (pip / pipx / uvx) - **As a command**: `pipx install d2b-sdk` or `uvx --from d2b-sdk d2b` (avoids pyenv shim accidents) - **As a project dependency**: `uv add d2b-sdk` + `uv run d2b` - **As a library** (imported from Python): `pip install d2b-sdk` Derived tables can be authored from the CLI too: `d2b transforms create NAME --workbook WB --sql-file f.sql --arg src=table` (`{{ src }}` placeholders + `--arg` bindings keep lineage traceable). Remove a workbook you no longer need with `d2b workbooks delete ID` (requires `workbooks:delete`, which a default login holds). --- # Core concepts Source: https://docs.d2b.dev/en/concepts Workbook / Source / Table / Sheet / Version / Job — plus optimistic locking, idempotency, the promotion model, governance and the per-row JSON Schema. ## Objects | Concept | In one line | |---|---| | Workbook | The working container (not a file). Bundles tables, sheets, versions and policies | | Source | An uploaded original (immutable). `auto` (fully automatic) or `staged` (explicit analyze → parse-spec edits → materialize) | | Table | A typed, row-identified, versioned dataset — the agent's main object. Base (editable) or derived (transform output) | | Transform | A SQL (Jinja2) or Python template. `{{ arg }}` placeholders bind inputs via `args` and produce an output artifact — the unit of lineage | | Sheet | A presentation composition that **owns no data**. Blocks reference tables (n:m); render composes an xlsx | | Chart | An agent-made chart: a config plus the recipe (tool + parameters) that produced it | | Version | `versions.commit(label)` = an immutable label on a snapshot. Revert restores derived tables through the snapshot machinery — base-table row edits are tracked in the edit log | | Job | A handle on an async operation (`async=true` uploads etc.). `jobs.wait()` or the `job.completed` webhook | | Workspace | The boundary object (members, billing, governance). Developers manage many workspaces through Account API keys | ## Row edits and optimistic locking Every write requires `expected_version` (the table's etag). On a mismatch you get a 409 — the SDK raises `ConflictError`. There is **no automatic retry** (concurrent edits are never silently overwritten): re-read (`rows()` returns `edit_version`), re-apply, retry. That is the contract. ```python page = client.tables.rows(wb, "sales") client.tables.upsert_rows( wb, "sales", rows=[{"__d2b_row_id": 3, "amount": 999}], # row_id present = update, absent = insert expected_version=page["edit_version"], ) ``` ## Idempotency The SDK attaches an `Idempotency-Key` to every mutating call (same key + same body replays the first response). Network-level retries can never double-apply. If you call HTTP directly, send the header yourself. ## The promotion model (raw never disappears silently) Auto ingestion builds D2B's structured tables from a faithful raw copy of each sheet. The structured tables take over the file's name and the raw tables are demoted to `state=archived` (the registry and lineage remain). `tables.lineage()` walks back to the raw tables, and `tables.unarchive()` re-materialises the original sheet when needed. With `structuring=defer` the raw tables land first and the same promotion happens when structuring finishes; if you built anything on a raw table by then, promotion is skipped automatically (downstream is never broken). ## Freshness (staleness) Interactive writes (row edits and the like) do not eagerly recompute downstream derived tables — they mark them **stale**. `GET .../tables/{name}` exposes `freshness: {stale, stale_since}`, and `POST /workbooks/{id}/recompute` (MCP `recompute_stale`) settles the whole backlog in dependency order. Transform runs propagate eagerly (their outputs are always fresh). ## Governance Column-tag × role policies (mask / deny) are **enforced in the data layer**. Masked columns come back as typed NULLs, reported in `masked_columns`. `GET .../tables/{name}/access` is a dry-run of what would happen. The same policy applies to export, profile, MCP and raw-file preview — there is no side door (on governed workbooks, raw-byte preview / revise are refused). ## Per-row JSON Schema (for constrained decoding) A static OpenAPI spec cannot type a table's **contents** (row shape depends on the data). So each table serves a "one row" JSON Schema at runtime: ``` GET /api/v1/workbooks/{wb}/tables/{name}/schema?format=json-schema ``` Over MCP: `get_schema(..., include_json_schema=true)`. The schema is generated from the policy-applied column set (denied columns don't appear), with `additionalProperties: false`, every cell nullable, and `__d2b_row_id` meaning "absent = insert / present = update". Agent harnesses can use it to constrain `upsert_rows` payloads at generation time. ## History - **snapshot**: an immutable commit. `POST /snapshots` / `POST /snapshots/{id}/restore` - **version**: a name on a snapshot (`POST /versions`, `POST /versions/{label}/revert`) - **op log**: every mutation, ordered (`GET /ops`). Row ops can be undone via `POST /ops/{id}/undo` (history is append-only) - **branch / merge**: `POST /export` with `record_branch=true` freezes the exported rows; `POST /tables/{name}/merge` brings the edited file back as a row-id-keyed, cell-level 3-way merge. Cells changed on both sides land in the conflict queue --- # Reading errors Source: https://docs.d2b.dev/en/errors problem+json (type / title / detail / suggested_fix), and a page per error type. Errors come back as RFC 7807-style `application/problem+json`. `type` is a stable identifier URI, and each one resolves to a page at `https://docs.d2b.dev/errors/`. ```json { "type": "https://docs.d2b.dev/errors/validation", "title": "Transform validation failed", "detail": "Avoid hard-coding source names in the template …", "status": 400, "trace_id": "8f3c…", "suggested_fix": "Use {{ arg }} placeholders and bind via `args`. See /api/v1/workbooks/{cid}/artifacts to discover names.", "alternative_tools": ["get_lineage"] } ``` `suggested_fix` is written so **an LLM can read it and decide its next action** — the recommended pattern is to pass the whole exception message back into your agent's loop. The SDKs raise exceptions carrying it; the CLI prints the same text to stderr. | type | Typical status | Meaning | |---|---|---| | [validation](/errors/validation) | 400 / 422 | The input doesn't meet the contract (failed SQL, name rules, hard-coded template references …) | | [propagation-failed](/errors/propagation-failed) | 400 | The transform was saved and materialised; a downstream transform then failed to rebuild | | [unauthorized](/errors/unauthorized) | 401 | Missing, malformed or revoked PAT | | [forbidden](/errors/forbidden) | 403 | Missing scope, a policy (mask / deny) refusal, or outside a workbook-scoped PAT | | [unknown-workbook](/errors/unknown-workbook) | 404 | The workbook doesn't exist or isn't visible | | [unknown-artifact](/errors/unknown-artifact) | 404 | The table/artifact doesn't exist, or its type isn't exposed on `/api/v1` | | [conflict](/errors/conflict) | 409 | Optimistic lock (`expected_version` drift), a busy workbook, or a duplicate name | | [rate-limited](/errors/rate-limited) | 429 | Rate limit hit. Honour `Retry-After` | | [internal](/errors/internal) | 5xx | A server-side failure. Report it with the `trace_id` | ## 5xx and trace_id 5xx responses use the same problem+json envelope. The `trace_id` (also in the `X-D2B-Trace-Id` response header) maps 1:1 to the server-side stack trace — include it when reporting. Failed async ingest jobs carry `trace_id` and the failing `phase` on the job record too. For surfaces where REST vocabulary (`GET /api/...`) doesn't fit, applicable errors also ship `suggested_fix_cli` (in `d2b ...` vocabulary). The CLI shows that variant automatically. --- # conflict Source: https://docs.d2b.dev/en/errors/conflict The state moved (409). `type`: `https://docs.d2b.dev/errors/conflict` — The state moved. Typical status: **409**. An optimistic-lock miss (`expected_version` differs from the current `edit_version`), a workbook busy with another operation, or a duplicate name (version labels, artifacts) — conflicts that **resolve by re-reading**. ## What to do - Optimistic lock: re-read `edit_version` via `rows()`, re-apply, resend. The SDK raises `ConflictError` and **never auto-retries** - `Workbook busy`: retry in a few seconds (the `suggested_fix` says so) - Version labels are immutable — pick a new one All error types: [Reading errors](/en/errors). --- # forbidden Source: https://docs.d2b.dev/en/errors/forbidden Not allowed (403). `type`: `https://docs.d2b.dev/errors/forbidden` — Not allowed. Typical status: **403**, or **402** when the cause is a paused workbook. A missing scope (a `workbooks:write` operation called with a `workbooks:read` key; deleting a workbook, dropping a table, restoring a snapshot or reverting a version needs `workbooks:delete`), a governance policy refusal (reading a denied column, raw-byte preview / revise on a governed workbook), or a PAT reaching outside its pin — the workbook or workspace it is scoped to, or the account it is bound to, including an explicit `workspace_id` the credential cannot address. ## Paused workbooks (402) A **402** with this type means the owner's free trial has ended and their workbooks are paused: reads and writes are refused, but nothing was deleted and the data is untouched. Every surface answers the same way — REST, MCP, the SDKs and the agent tools — because the check sits at the single point where a workbook is opened. Taking up any plan (pay-as-you-go with a card on file is $0/month) resumes every paused workbook immediately. There is no restore step and no waiting: the objects were never moved. ## What to do - `detail` says which scope or policy is the cause. Mint a PAT with the required scope; for a `d2b login` credential, `suggested_fix_cli` names the login command to re-run (a login holds the whole workbooks family unless `--scopes` narrowed it) - For policy causes, `GET .../tables/{name}/access` dry-runs what would be masked / denied - A pinned PAT not reaching another workbook or workspace is the intended least privilege; `GET /api/v1/me/workspaces` lists what a credential reaches - On a **402**, do not retry and do not re-mint the PAT — neither is the cause. Take up a plan, then replay the request unchanged All error types: [Reading errors](/en/errors). --- # internal Source: https://docs.d2b.dev/en/errors/internal A server-side failure (5xx). `type`: `https://docs.d2b.dev/errors/internal` — A server-side failure. Typical status: **5xx**. An unexpected failure on D2B's side. The SDKs retry 5xx automatically (idempotency keys make that safe). ## What to do - If it persists, report it together with the `trace_id` - For long operations, prefer jobs (`async=true`) and check `GET /jobs/{id}`'s `error` All error types: [Reading errors](/en/errors). --- # propagation-failed Source: https://docs.d2b.dev/en/errors/propagation-failed Your transform was saved; a downstream transform then failed to rebuild (400). `type`: `https://docs.d2b.dev/errors/propagation-failed` — The write succeeded, a downstream rebuild did not. Typical status: **400**. Unlike [validation](/errors/validation), **nothing about your request was wrong and nothing was rolled back**. The transform you sent was saved, its output table was materialised, and its bindings and lineage were recorded. What failed afterwards is a *separate*, already-existing transform downstream of the artifact you just wrote: the eager recompute could not rebuild it against the new output. Typical cause: the new version of your transform changes the output's schema (a dropped or renamed column, a changed type) and a downstream transform still reads the old shape. ## What to do - **Do not resend the same request expecting a different result** — your transform is already stored and materialised. Resending re-runs it and hits the same downstream failure - `detail` carries the underlying error from the transform that failed to rebuild. Read it to find which downstream step broke - Fix that downstream transform (usually: update it for the new schema), then re-run it. `GET /api/v1/workbooks/{id}/transforms` lists the stored templates, and the lineage DAG shows what depends on your artifact - The downstream artifact still holds its **previous** content — the rebuild did not land, so it no longer reflects your new output. Re-run it explicitly once fixed; it is not queued for you All error types: [Reading errors](/en/errors). --- # rate-limited Source: https://docs.d2b.dev/en/errors/rate-limited Rate limited (429). `type`: `https://docs.d2b.dev/errors/rate-limited` — Rate limited. Typical status: **429**. The per-key limit (`rate_limit_per_minute` set at minting) or a per-endpoint limit was exceeded. The SDKs retry with exponential backoff. The per-key limit is one budget for the whole key: every authenticated request counts against it, whichever endpoint it calls, and the MCP server shares the same budget. A per-endpoint limit is separate and applies to that endpoint alone. ## What to do - Honour the `Retry-After` header - Batch row writes (up to 1,000 rows per `upsert_rows` call); use `async=true` + `jobs.wait` for ingestion - Set `rate_limit_per_minute` when you mint the key. A key can never mint a wider one than itself, so raise the limit on the key you mint from All error types: [Reading errors](/en/errors). --- # unauthorized Source: https://docs.d2b.dev/en/errors/unauthorized Not authenticated (401). `type`: `https://docs.d2b.dev/errors/unauthorized` — Not authenticated. Typical status: **401**. The `Authorization: Bearer ` header is missing or malformed, the token was revoked or expired, or the CLI's saved login lapsed (90 days). ## What to do - `d2b login` again (CLI), or re-issue the PAT from the console - Check `D2B_API_KEY` is non-empty and starts with `d2b_pat_` - `/api/control` takes Account API keys; `/api/v1` takes PATs — make sure you're not crossing them All error types: [Reading errors](/en/errors). --- # unknown-artifact Source: https://docs.d2b.dev/en/errors/unknown-artifact The table / artifact can't be found (404). `type`: `https://docs.d2b.dev/errors/unknown-artifact` — The table / artifact can't be found. Typical status: **404**. No artifact has that name (or `art_…` id), or its type isn't exposed on `/api/v1` (charts are read through `/charts`). Names match exactly, NFC-normalised, case-sensitive. ## What to do - Enumerate current names with `GET /api/v1/workbooks/{id}/artifacts` (after a rename, use the new display name) - A raw table archived by promotion comes back with `tables.unarchive()` - Charts: `GET /workbooks/{id}/charts` All error types: [Reading errors](/en/errors). --- # unknown-workbook Source: https://docs.d2b.dev/en/errors/unknown-workbook The workbook can't be found (404). `type`: `https://docs.d2b.dev/errors/unknown-workbook` — The workbook can't be found. Typical status: **404**. The id is wrong, or the workbook isn't visible to the calling principal (another workspace's, deleted, or paused). ## What to do - Enumerate what you can see with `GET /api/v1/me/workbooks` - For workspaces managed through an Account API key, check the key's `workspace_id` pinning - A paused workbook (Free trial ended) needs resuming — subscribing to any plan does it instantly All error types: [Reading errors](/en/errors). --- # validation Source: https://docs.d2b.dev/en/errors/validation The input doesn't meet the API contract (400 / 422). `type`: `https://docs.d2b.dev/errors/validation` — The input doesn't meet the API contract. Typical status: **400 / 422**. Failed SQL (missing table, syntax), forbidden characters in `artifact_name`, a template that hard-codes an input table name (inputs must bind through `{{ arg }}` placeholders), an empty template, `record_branch` without `include_row_ids` — errors **the caller can fix**. ## What to do - `detail` carries the server's original error text verbatim (DuckDB / Jinja2 messages). Read that first - Templates output through `{{ artifact_name }}` and take inputs through `{{ arg }}` bound via `args`. Discover names with `GET /api/v1/workbooks/{id}/artifacts` - A 422 is a Pydantic type error (a value outside an enum, etc.). Match the request shape to the OpenAPI spec All error types: [Reading errors](/en/errors). --- # Your workbook in git Source: https://docs.d2b.dev/en/git-sync d2b pull / push — bring transforms, sheets, charts and base tables into a repository, review them in a PR, push them back. Automate the loop with GitHub Actions. 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. ```bash 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 ``` ## Layout | Directory | Contents | pull | push | |---|---|---|---| | `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. ```json { "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": "…"}} } ``` ## Syncing a whole workspace 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/--<first 8 of its id>/` with the layout above, its own `d2b.json` included. ```bash 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. ## Conflict rules - 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) ## Automating the loop with GitHub Actions No GitHub App and no D2B-side integration — the CLI alone closes the loop between a repository and a workbook. ```bash 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(...)`. --- # Connect over MCP Source: https://docs.d2b.dev/en/mcp Use D2B's tools from Claude / Cursor / VS Code. Streamable HTTP. Auth is the host's sign-in (OAuth 2.1) or a Bearer PAT. The operating norms arrive from the server as instructions. import { Tabs, TabItem } from "@astrojs/starlight/components"; D2B's MCP server lives at `https://d2b.dev/mcp/` (Streamable HTTP). Auth comes in two forms: the host's **sign-in** (OAuth 2.1 — approve in the browser, and the host renews a one-hour token on its own) or a PAT Bearer header. The tools are the same surface as REST / CLI / SDK, each with a typed schema for tool calling (your host's tool list is always the current one). ## The norms come from the server On connect, the initialize response carries `instructions`: the operating norms (which tool when, bring files in by reference, derive with transforms, re-read on 409, `commit_snapshot` at milestones, `delete_workbook` to clean up …). If your host truncates `instructions`, the same text is readable as the `d2b://guide` resource. Your repo only needs the connection and the choice of path ([Use D2B from a coding agent](/en/agents)). Workspace provisioning works over MCP too (`provision_workspace` / `delete_workspace`; needs an account-wide key with the `workspaces:create` / `workspaces:delete` scopes — a key pinned to one workspace cannot create siblings). ## Sign-in (OAuth) Register the URL and nothing else: the host discovers the authorization server from the 401 on `https://d2b.dev/mcp/` and opens D2B's consent page in the browser. The page shows the app's name, the host it returns to, the permissions it gets (read / write / delete on workbooks plus cloud-files:read) and **which account it acts for** (your default account or one of your developer accounts). The access token lasts one hour; the host renews it with a refresh token. It appears under **Settings > Tokens** in the console as `oauth:<app name>`, so each connection can be revoked on its own. The app name is self-declared by the host; D2B does not verify it. Deny a consent page you did not expect. For hosts without OAuth support, and for unattended environments such as CI, keep using a PAT Bearer header. ## Per-host setup <Tabs> <TabItem label="Claude Code"> ```bash claude mcp add d2b --transport http https://d2b.dev/mcp/ ``` Registered without a header, the host asks you to sign in on first use. Unattended environments pass a PAT instead: ```bash claude mcp add d2b --transport http https://d2b.dev/mcp/ \ --header "Authorization: Bearer $D2B_PAT" ``` </TabItem> <TabItem label="Cursor"> `~/.cursor/mcp.json` (or the project's `.cursor/mcp.json`). The URL alone makes Cursor prompt for sign-in; add headers to pin a PAT instead: ```json { "mcpServers": { "d2b": { "url": "https://d2b.dev/mcp/", "headers": { "Authorization": "Bearer d2b_pat_..." } } } } ``` </TabItem> <TabItem label="VS Code"> `.vscode/mcp.json`. The URL alone makes VS Code prompt for sign-in (add headers for a PAT): ```json { "servers": { "d2b": { "type": "http", "url": "https://d2b.dev/mcp/", "headers": { "Authorization": "Bearer ${input:d2b_pat}" } } }, "inputs": [{ "id": "d2b_pat", "type": "promptString", "password": true, "description": "D2B PAT" }] } ``` </TabItem> <TabItem label="Claude Desktop / claude.ai"> Settings → Connectors → *Add custom connector* with `https://d2b.dev/mcp/`. Keep the default **"Sign in now"** (the browser opens D2B's consent page). To connect with a PAT instead, choose "No sign-in" and set the request header `Authorization: Bearer d2b_pat_...`. On versions that offer neither, go through `mcp-remote`: ```json { "mcpServers": { "d2b": { "command": "npx", "args": ["-y", "mcp-remote", "https://d2b.dev/mcp/", "--header", "Authorization: Bearer d2b_pat_..."] } } } ``` </TabItem> </Tabs> If you use a PAT, make it least-privilege — **workbook-scoped, with a rate limit** ([Quickstart §1](/en/quickstart)). ## Tool map | Purpose | Tools | |---|---| | Discover | `list_my_data` / `get_schema`(`include_json_schema`) / `profile_table` / `get_lineage` / `get_downstream` / `search` | | Read | `read_table` / `query_sql` / `validate_sql` | | Review | `review_table` / `review_workbook` (evidence-backed findings: the source file's formulas, the table's invariants, labels vs. definitions; `mode="agent"` returns a job — follow it with `get_job`) | | Bring data in (reference first) | `ingest_url` / `request_upload` → `ingest_upload` / `list_cloud_files` → `import_cloud_file` / `track_onedrive_file` / `ingest_file` | | Inspect structure / faithful revise | `analyze_source` / `get_parse_spec` / `update_parse_spec` / `materialize_source` / `revise_source` | | Containers (workbooks, workspaces) | `list_my_workspaces` / `create_workbook` / `delete_workbook` / `provision_workspace` / `delete_workspace` | | Create & fix | `create_table` / `add_transform` / `list_transforms` / `upsert_rows` / `delete_rows` / `write_a1` / `put_sheet` / `get_sheet` / `list_sheets` | | Schema | `add_column` / `rename_column` / `retype_column` / `drop_column` / `rename_table` | | Formulas & descriptions | `set_formula_column` / `list_formula_columns` / `clear_formula_column` / `set_artifact_description` / `set_column_description` / `set_table_style` / `get_table_style` | | History | `list_snapshots` / `commit_snapshot` / `restore_snapshot` / `diff_snapshots` / `recompute_stale` / `list_ops` / `undo_op` | | Conflicts, sync, delivery | `list_conflicts` / `resolve_conflict` / `export_tables` / `bind_external_sheet` / `list_sync_bindings` / `unbind_external_sheet` / `list_file_links` / `sync_file_link` | ## Typing Tool inputs are strictly typed (enums / structured models, so a harness can reject bad values at generation time). The one data-dependent input, `upsert_rows.rows`, can be constrained at runtime with the per-row JSON Schema from `get_schema(name, include_json_schema=true)`. Errors use the same problem+json vocabulary as REST (with `suggested_fix`) — [Reading errors](/en/errors). The transport is **stateless streamable HTTP** — no conversation session is kept server-side; each request is self-contained (safe for retries and horizontal scaling). --- # Plans and pricing Source: https://docs.d2b.dev/en/plans Billing is metered in data operations (ops) and storage. The biggest difference between Free and paid is the data lifecycle (trial and pause). **Capability is identical on every tier** — models, tools, API / MCP / SDK / CLI, lineage, governance: nothing is gated by plan. Tiers differ only in **volume (ops / storage), concurrency, scale, guarantees, payment methods, the data lifecycle and collaboration (inviting members and sharing start at Pro)**. ## The billing axes - **op (write operation)**: **one op per write operation** — an ingest, a transform run, a row edit, a commit, a version operation (editing a single row is 1 op). An operation writing more than 1,000 rows (a transform, an ingest) counts **one op per 1,000 rows**, rounded up (e.g. ingesting 1,000,000 rows = 1,000 op, a 500,000-row transform = 500 op). Reads are currently not billed - **Storage**: GB-months - **LLM is an add-on**: calls from your own agent with your own LLM carry no LLM charge. Only when you use D2B's built-in agent (chat) is its LLM usage added - **Included allowances don't roll over**: plan-included ops / storage are per billing period (unused balance expires at renewal). Purchased packs and auto-recharges are valid for six months from purchase | | Free | Pay as you go | Solo | Pro | Enterprise / OEM | |---|---|---|---|---|---| | Monthly | **$0** (no card) | **$0** (card on file) | **$29** | **$199** | Custom | | Annual | — | — | **$290** (2 months free) | **$1,990** (2 months free) | Custom | | Included | None (signup bonus only) | None (pure usage) | 10M ops + 20 GB / mo | 100M ops + 200 GB / mo | Contracted | | Workspaces | 3 | 3 | 10 | Unlimited | Unlimited | | Members & sharing | — | — | — | 5 member seats included, +$15/mo per additional member. Guests (view-only) free up to 100 | Yes | | Op overage | Not available | $6 / 1M ops | $5 / 1M ops | $4 / 1M ops | Volume | | Storage overage | Not available | $0.18 / GB-mo | $0.15 / GB-mo | $0.12 / GB-mo | Volume | | **Pause** | **14 days** (trial ends) | **Never** | Never | Never | Never | | Support | Community | Community | Email | Priority | Dedicated + Slack | | Payment | — | Card | Card | Card + invoicing | Contract, annual prepay | ## Trial and pause - **Free is a 14-day trial.** When it ends, the workbook pauses — reads and writes stop, and the data stays exactly as it is - Moving to [Pay as you go](https://d2b.dev/developer/billing) (just a card, $0/month) resumes it right where you left off - **Subscribed plans never pause.** From PAYG up, everything keeps working even when idle ## One wallet per account - Plan, credits, ops and storage balances all live on the **Account**. Users, workspaces and memberships hold no balance of their own. - A personal workspace draws on your Account; a team workspace draws on its **owner's (admin's) Account**. Members need no plan of their own — their usage comes out of the owner's balance. - Workspaces count against the owner's plan limit, and inviting members or sharing is available only while the owner is on Pro. Leaving Pro suspends the members; the data stays. ## Pay as you go No monthly fee — just a card on file, billed for what you use ($6 / 1M ops, $0.18 / GB-month, balance auto-recharge). It is also **the cheapest way to lift a Free pause** — the natural home for "I want to keep the data but don't use it every month". Past roughly 4.8M ops a month (≈ $29 worth), Solo becomes cheaper. Balance auto-recharge is optional: switching it off keeps you on PAYG for as long as a card is on file — you do not fall back to Free or to its pause. Buying a credit pack registers the card too, and a Free wallet switches to PAYG at the same time. Auto-recharge is enabled through the same card-registration flow. :::note Per-plan rate limits and concurrency caps are being enabled gradually. The console's [Plan & Billing](https://d2b.dev/developer/billing) is authoritative for current effective values, balance and plan changes. ::: --- # Quickstart Source: https://docs.d2b.dev/en/quickstart Mint a PAT and make your first call from Python, curl or the CLI in five minutes. ## 1. Authentication — mint a PAT Every `/api/v1` call uses PAT (Personal Access Token) Bearer auth. Mint one from the [console](https://d2b.dev/developer/keys), or from the API with a logged-in session (JWT): ```bash curl -X POST https://d2b.dev/api/v1/me/tokens \ -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \ -d '{"name": "my-agent", "scopes": ["workbooks:read", "workbooks:write"]}' # → {"token": "d2b_pat_...", ...} (the token is shown in plaintext only in this response) ``` - Scopes are resource×action: `workbooks:read/write/delete`, `cloud-files:read` (list / import from Drive / OneDrive — on by default for logins, explicit on PATs), `governance:configure`, `workspaces:read/create/configure/delete`, `keys:read/mint/revoke`, `account:read`, `billing:manage`. Implication stays within one resource (write ⊃ read); deleting a workbook, restoring a snapshot or reverting a version needs `workbooks:delete` - A token is always bound to exactly one account (one token = one account). Minting from a logged-in session targets your default account; pass `account_id` to target one of your developer accounts instead. `GET /api/v1/me` reports `account_id` / `account_name` - `resource` narrows the reach: `workbook:<id>` (that workbook only — the recommended shape when handing a token to an agent) or `workspace:<id>` (that workspace's workbooks only; creates land there too). The default `account` is everything under the account. Reach is set by `resource` alone; scopes say what the key may do — a key without `workspaces:read` still creates in any workspace of its account when pinned to `account`. Naming a workspace the key does not reach in `workspace_id` is a 403 for listing and creation alike - `GET /api/v1/me/workspaces` lists the workspaces the token reaches, by name (`is_default` = where a create lands when `workspace_id` is omitted). `/me` and the workbook listing carry `workspace_name` as well - Workspace provisioning is `workspaces:create`, key minting is `keys:mint`, billing operations are `billing:manage` — permissions are independent resource×action scopes with no umbrella scope; mint keys carrying exactly what they need (GitHub fine-grained PAT style) - A create that omits `workspace_id` lands in the token's account's oldest workspace (the personal workspace for the default account). A developer account with no workspace returns 409 — create one first in the console (Workspaces) or with a `workspaces:create` key (`POST /api/control/workspaces`) ## 2. Five minutes in Python ```bash pip install d2b-sdk ``` ```python from d2b import D2BClient client = D2BClient(api_key="d2b_pat_...", base_url="https://d2b.dev") # 1) A workbook is the working container wb = client.workbooks.create(title="monthly-sales")["id"] # 2) Throw the messy Excel at it. wait=True has the SDK babysit the 202+job. # After extraction and structuring you get clean, typed tables result = client.sources.upload(wb, "sales_2026-06.xlsx", wait=True) # 3) See what landed (the basic agent move) for t in client.tables.list(wb): print(t["name"], t["row_count"]) schema = client.tables.schema(wb, "sales") # 4) Analyse with SQL (governance applied, read-only) out = client.query.sql(wb, 'SELECT product, sum(amount) FROM "sales" GROUP BY 1') # 5) Derived tables are transforms (lineage is preserved) client.transforms.create( wb, name="agg/monthly", kind="sql", template='CREATE OR REPLACE TABLE "{{ artifact_name }}" AS ' 'SELECT product, sum(amount) AS revenue FROM "{{ src }}" GROUP BY 1', artifact_name="product_sales", args={"src": "sales"}, ) # 6) Back to humans xlsx = client.export.tables(wb, tables=["product_sales"]) # formatted xlsx original = client.sources.render_template(wb, "sales_2026-06.xlsx") # original formatting, values refreshed # 7) Pin a version client.versions.commit(wb, "2026-06") ``` ## 3. The same thing in curl ```bash BASE=https://d2b.dev; H="Authorization: Bearer $D2B_PAT" WB=$(curl -s -X POST $BASE/api/v1/workbooks -H "$H" -H "Content-Type: application/json" \ -d '{"title": "monthly"}' | jq -r .id) curl -s -X POST $BASE/api/v1/workbooks/$WB/sources -H "$H" -F file=@sales.xlsx -F mode=auto -F async=true # → {"job_id": ...} → poll GET $BASE/api/v1/jobs/{job_id} curl -s $BASE/api/v1/workbooks/$WB/tables -H "$H" curl -s -X POST $BASE/api/v1/workbooks/$WB/query -H "$H" -H "Content-Type: application/json" \ -d '{"sql": "SELECT count(*) FROM \"sales\""}' ``` ## 4. Or the CLI ```bash pipx install d2b-sdk # or uvx --from d2b-sdk d2b … d2b login d2b workbooks create --title monthly d2b upload sales.xlsx --workbook WB --wait d2b query 'SELECT count(*) FROM "sales"' --workbook WB ``` Next: [Core concepts](/en/concepts), [Connect over MCP](/en/mcp), [Your workbook in git](/en/git-sync). ## Messy Excel — when auto, when staged Start with the default `mode=auto` (D2B cuts each sheet into its tables and reads merged headers, unit rows, subtotal rows and hierarchy to shape them). Drop to `staged` **only when extraction or structuring missed**: 1. Re-upload the same file with `mode=staged` (bytes only, zero interpretation) 2. `POST .../sources/{name}/analyze` → review and correct the returned parse spec (header row, data range, types) 3. `POST .../sources/{name}/materialize` to commit The decision flow is: auto → eyeball the result → if it missed, take control of the parse spec via staged. The auto result stays under its own name, so you can compare while fixing. The upload response's `structuring` field states the outcome: `structured`, `skipped` (you passed `structuring=skip` — the raw tables only), `deferred` (you passed `structuring=defer` — swapped in later), `raw_fallback` (structuring was requested but the raw tables came back; `structuring_reason` says why — `no_credits`, `building` (re-upload to retry), `failed:…` — or switch to staged), or `off` (structuring is disabled on this server). The CLI prints a stderr warning on `raw_fallback`. Uploads through the API / CLI / SDK / MCP are **structured by D2B by default** (`structuring=auto`): each sheet is split into its tables and the titles and notes around them (`<sheet>_補足情報`), and the tables cut from it stay as they are laid out on the sheet. From those, tables with periods across the columns are reshaped to long form, and report tables with subtotals and totals are split into one table per level of their hierarchy (`<table>_階層1`, `<table>_階層2` …; totals and differences computed from rows of the same level go to `<table>_階層1_計算項目` and so on) and their account tree (`<table>_科目`). A sheet that is a single table with nothing around it is not split, and its shaped table takes over the file's name. The original sheet stays in lineage. Structuring spends the workbook's credits (uploading the same content again is served from the cache, with no structuring charge). To reshape the raw tables with your own LLM and `add_transform` instead, pass `--no-structuring` (API: `structuring=skip`): each sheet lands as it is, in ~1s, for free. `--defer-structuring` (`structuring=defer`) returns the raw tables now, structures in the background and swaps the structured tables in when ready (`artifact.updated` fires on the swap). --- # SDKs (Python / TypeScript) and REST Source: https://docs.d2b.dev/en/sdks d2b (Python) and d2b-sdk (TypeScript): automatic Idempotency-Keys, 429/5xx retries, suggested_fix-bearing exceptions. The SDKs are a thin skin over REST (every method maps to one endpoint). Shared behaviour: - Every mutating call carries an automatic `Idempotency-Key` (retry-safe) - 429 / 5xx retry with exponential backoff. **409 (optimistic lock) is never retried** — re-read and re-apply; that is the contract - Errors become exceptions carrying the problem+json `suggested_fix` (readable by an LLM as-is) - Practical helpers: `jobs.wait()` / `tables.iter_rows()` / `webhooks.verify_signature()` ## Python ```bash pip install d2b-sdk ``` ```python from d2b import D2BClient, ConflictError client = D2BClient(api_key="d2b_pat_...", base_url="https://d2b.dev") wb = client.workbooks.create(title="monthly")["id"] client.sources.upload(wb, "sales.xlsx", wait=True) page = client.tables.rows(wb, "sales") try: client.tables.upsert_rows(wb, "sales", rows=[{"product": "apple", "qty": 3}], expected_version=page["edit_version"]) except ConflictError: page = client.tables.rows(wb, "sales") # re-read → re-apply ``` Resources: `workbooks` / `sources` / `tables` / `query` / `transforms` / `versions` / `jobs` / `reviews` / `sheets` / `charts` / `export` / `webhooks`. ## TypeScript ```bash npm install d2b-sdk ``` ```ts import { D2BClient } from "d2b-sdk"; const client = new D2BClient({ apiKey: "d2b_pat_...", baseUrl: "https://d2b.dev" }); const wb = (await client.workbooks.create({ title: "monthly" })).id as string; await client.sources.upload(wb, fileBlob, { filename: "sales.xlsx", wait: true }); const grid = await client.tables.a1(wb, "sales", "A1:D10"); // read in Excel coordinates await client.sheets.put(wb, "summary", [ { kind: "heading", text: "Monthly summary" }, { kind: "table_view", table: "sales" }, ]); const xlsx = await client.sheets.render(wb, "summary"); ``` ## Where the packages live | | Package | Source and issues | |---|---|---| | Python + CLI | [d2b-sdk on PyPI](https://pypi.org/project/d2b-sdk/) | [github.com/600/d2b-sdk](https://github.com/600/d2b-sdk) (`python/`) | | TypeScript | [d2b-sdk on npm](https://www.npmjs.com/package/d2b-sdk) | [github.com/600/d2b-sdk](https://github.com/600/d2b-sdk) (`typescript/`) | The distribution is `d2b-sdk` on both registries. The Python import package and the command are both `d2b` (`d2b` on PyPI is an unrelated project), so with `uvx` that reads `uvx --from d2b-sdk d2b …`. The GitHub repository is a read-only mirror (the SDKs are developed in D2B's main repository): pull requests can't be merged there, issues are welcome. Each release syncs the same tag (`sdk-py-v*` / `sdk-ts-v*`) to the mirror, which publishes to both registries (npm with provenance). ## Raw REST - Base URL: `https://d2b.dev` (paths under `/api/v1/...`; tenant management under `/api/control/...`) - Auth: `Authorization: Bearer <PAT>` (`/api/v1`), Account API key (`/api/control`) - Mutations accept an `Idempotency-Key` header (same key + same body replays the first response) - OpenAPI 3.1: [openapi.json](https://docs.d2b.dev/openapi.json) — the [API reference](/en/api-reference) is generated from it --- # Webhooks Source: https://docs.d2b.dev/en/webhooks Receive events like artifact.updated / job.completed, signed with HMAC-SHA256. ```python hook = client.webhooks.create("https://example.com/hook", events=["artifact.updated", "job.completed"]) secret = hook["secret"] # available only in this response # On the receiving side from d2b.client import _Webhooks ok = _Webhooks.verify_delivery(secret, request.headers, raw_body) # signature + send time (default: within 5 min) ``` - Signature: `X-D2B-Signature-V2: sha256=<hex>` = HMAC-SHA256(secret, `"{X-D2B-Delivery}.{X-D2B-Timestamp}." + raw_body`). `X-D2B-Timestamp` is the send time of that attempt (UNIX seconds, refreshed on every retry): reject a request whose age exceeds your tolerance (for example 5 minutes) to defeat replays. `X-D2B-Delivery` is the delivery id (the same on every retry). Verify against the **raw body** (do not re-serialise) - The legacy `X-D2B-Signature: sha256=<hex>` = HMAC-SHA256(secret, raw_body) is still sent. It carries no send time, so it cannot stop a replay - Events: `artifact.updated` / `artifact.deleted` / `artifact.stale` / `snapshot.committed` / `source.analyzed` / `source.materialized` / `conflict.created` / `version.branched` / `version.merged` / `job.completed` - Delivery history: `GET /api/v1/me/webhooks/{id}/deliveries` - Retries: non-2xx responses and connection failures are retried up to 14 times with exponential backoff from 30 s to 1 h (about 8 hours in total). A delivery that still fails stays visible under `deliveries` but is not retried again. Each event produces one delivery, but an attempt whose response never arrived is retried, so the same delivery (the same `X-D2B-Delivery`) can reach you more than once: process deliveries idempotently, keyed by `X-D2B-Delivery`. The payload's `timestamp` is the event's time and does not change on retry For long operations (big ingests), prefer `async=true` + waiting on `job.completed` over polling — cheaper, and friendlier to agent timeouts.