Plank help · updated 2026-09-19

App data & databases

How a Plank app stores structured data that persists between runs, kept separate per workspace, so dashboards remember things over time.

Agents: fetch the raw markdown of this page at /en/help/app-data.md

App data & databases

Most of what Plank produces is a file — a report, a sheet, a slide deck. But some apps need to remember things: a list of leads, a tracker of open tasks, the rows behind a dashboard. For that, a Plank app can keep its own small database in your workspace.

This page explains what that storage is, where it lives, and how an app reads and writes it.

What app data is

When the agent builds a data-backed app, it can create real tables — the same kind of structured rows and columns you'd picture in a spreadsheet or a database. A "leads" table might have columns for name, company, and status; each lead is one row.

Unlike a one-off file, these tables persist. Close the workspace, come back tomorrow, run the app again — the rows are still there. That's what lets a dashboard show "12 open tasks" today and "9 open tasks" next week instead of starting from scratch every time.

You don't create these tables yourself. You ask the agent — "build me a simple CRM to track leads" — and it sets up the tables and the app around them. This is built into every workspace; there's nothing to install or turn on.

Want a worked example? Build a sales pipeline CRM is a complete, copy-ready recipe — a deals table plus a drag-and-drop kanban board that saves every move. Any workspace can ask for it.

Seeing your tables in the sidebar

Once a workspace has at least one table, a Tables section appears in the sidebar, below Integrations, listing each table with its row count. Workspaces with no tables yet don't show this section at all — there's nothing to see until the agent builds something that needs one.

Click a table to open it as a read-only grid, right alongside your open files. You can scroll through the rows and click a column header to sort by it. The grid shows the first 500 rows, and if there are more it tells you — "500 of 1,240 rows" — with a refresh button to pull the latest data on demand.

This view is for looking, not editing: you can't create, rename, or delete a table here, and you can't add, change, or remove a row. That's still the agent's job — ask it in plain English, the same way you always have ("add a notes column to the tasks table," "delete the duplicate lead"). The Tables section just gives you a quick, direct way to check what's actually stored, without asking the agent to show it to you.

Your data stays in your workspace

Each workspace gets its own private space for app data. One workspace's tables and rows are completely separate from another's — an app running in your workspace can only see your workspace's data, never anyone else's.

The separation is structural, not a matter of the app remembering to filter correctly: by default, data from a different workspace isn't reachable from your app — unless a dashboard is deliberately built to reach another workspace you also belong to (see below). You don't have to configure anything for the default — it's how the storage works out of the box, and a dashboard can never reach a workspace you're not a member of.

Showing data from another workspace (if you're a member of both)

If you belong to more than one workspace, a dashboard can be built to deliberately look at another one — for example, one home workspace showing a rollup of leads from a client workspace you also have access to. Instead of fetch('apps/leads'), the dashboard points at fetch('apps/~<workspaceId>/leads'), naming the other workspace by id. A dashboard can discover the ids of the workspaces you belong to with fetch('me/workspaces'). Reading still requires you to be a member of that workspace; writing to it requires more than read-only access there. This only happens when a dashboard is built to ask for it by id — nothing crosses workspaces on its own.

How an app reads and writes its data

Under the hood, an app works with its tables through a small set of plain operations:

OperationWhat it does
QueryList the rows in a table (optionally filtered, sorted, or limited).
InsertAdd a new row, returning its generated id.
Insert manyAdd up to 500 rows in one call.
Import rowsLoad a whole file of rows — up to 20,000 — without the agent retyping any of them.
CountAsk how many rows a table holds, without reading them.
UpdateChange an existing row, found by its id.
Update manyChange up to 500 rows by id in one call.
DeleteRemove a row by its id.

Every row automatically gets a unique id when it's created, which is how later updates and deletes find it.

Loading a lot of rows at once

Importing a spreadsheet, a bank statement, a list of contracts — anything where the answer is "hundreds of rows" — is one call, not hundreds. The agent has plank_app_insert_many and plank_app_update_many for exactly this: each takes up to 500 rows and writes them in a single transaction.

Two things follow from "a single transaction", and both are useful to know if you're watching an import:

  • A batch lands whole or not at all. If one row is malformed, nothing from that call is written — the agent gets told which row and can fix it and retry the same batch. You never end up half-imported and having to work out where it stopped.
  • An id that matches no row isn't a failure. On a bulk update, ids that don't exist come back listed, so the agent can tell you "212 updated, 3 not found" instead of losing the whole batch to three stale ids.

If you have more than 500 rows, that's several calls — still a handful, not one per row.

When the same batch may arrive twice — an inbound message feed, a webhook a provider replays, anything that is delivered at least once rather than exactly once — put a UNIQUE constraint on whatever id the source itself gives each item, then pass onConflict: "ignore" on the bulk insert. A row that duplicates one already in the table is then skipped, the rest still land, and the reply says how many were skipped. Without it, one repeat rolls the whole batch back and takes the genuinely new rows with it. On a one-off import, leave it off: there a duplicate key is a mistake worth seeing.

Writing rows one at a time, or splitting the work across parallel helper agents, is always wrong here — it is slower and burns far more of your budget for the same result. A workspace agent that tries it will be stopped and told to use the bulk call instead.

Importing a spreadsheet: the file goes in, not the rows

There is one thing faster than a bulk call, and it matters most for exactly the job people ask for most — "here are my Excel registers, make them a table".

A bulk call still has a cost that has nothing to do with the database: the agent has to write out every row as part of the instruction it sends. That is typing, and typing is slow. Measured on a real import of 166 contract rows: the database did its part in 5 seconds, and the agent spent 9 minutes spelling the rows out — about a minute per 20 rows, and it was copying them from a file it had already made.

So the agent has plank_app_import_rows, which takes a file path instead of rows. It reads the file itself and writes it to the table in 500-row batches. The same 166 rows land in under a second, and 5,000 rows in about four — the cost stops depending on how many rows there are.

What that looks like when you ask for it:

  1. The agent looks at your workbook in one step — it gets back the column names, the row count and the first few values of each column, and the workbook is converted to a plain row file at the same time. It no longer writes a parser to find that out.
  2. It creates the table, usually with a uniqueness rule on whatever identifies a row (a contract number, a statement reference) so a re-run can't duplicate anything.
  3. It imports the file in one call, naming which spreadsheet heading becomes which column, and then asks the table for its row count to confirm — a count, not a re-read of every row, which is what keeps the last step from getting slower as your register grows.

Step 3 is worth knowing about: your headings stay yours. A column called «№ договора» becomes a stored column called document_number — the agent writes that correspondence once, for the columns, never for the rows. Headings it doesn't name simply aren't imported, which is how a working column or a stray total gets left behind.

A workbook itself (.xlsx) is not handed to the import directly — it is converted first, in that same first step, because reading the sheet is how the agent works out the columns anyway.

The data goes in before anything is built on it. If the agent creates a table and then reaches for a helper to build the dashboard before any rows exist, it is told once to load the data first — the dashboard would only have to be revisited when the rows landed, and the rows are what you asked for. This was measured: in the import above, the dashboard was being built for eleven minutes against a table that was still empty.

Two things worth knowing if you're watching a big import:

  • Each 500-row batch is its own transaction. Under 500 rows, the whole import lands or none of it does. Above that, if batch 7 of 10 fails you are told exactly how many rows are already in, so the retry starts from the right place rather than duplicating what landed.
  • An empty cell becomes empty, not the text "". Spreadsheet exports are full of blank cells, and a blank date that arrives as text is the usual reason an otherwise-good import is rejected.

For example, a lead-tracker app might:

  1. Insert a row when you add a new lead.
  2. Query the "leads" table to show them all on a dashboard.
  3. Update a lead's status from "new" to "contacted".
  4. Delete a lead that turned out to be a duplicate.

The agent has a matching set of tools for the same jobs — it can list your workspaces, create a table, add or rename columns, link two tables together, and query, insert, update, or delete rows one at a time or in bulk. So you can ask in plain English ("mark the Acme lead as won," "add a notes column to the tasks table") and the agent does the right operation for you.

Getting answers back from a person: a table, never a file

A workbook is an output. Plank renders .xlsx and .csv as a read-only grid — you can select a cell and switch sheets, and that is all. There is no cell to type in and no Save.

So a file can never be the way you collect answers. The move that looks obvious — write a sheet with an empty "Decision" column, hand it over, ask the person to fill it in — silently throws their work away: whatever they type is not saved, and the agent reads the same empty column back. Measured on 2026-09-10: an accountant spent an hour on 125 rows and the file came back with 0 of 125 decisions filled.

When you need a person to answer row by row, put the rows somewhere writable:

  1. Import them into a tableplank_app_preview_rows_file on the workbook, then plank_app_import_rows on the row file it names. (You may already have done this; the same first step is what tells you the columns.)
  2. Add the answer columns to the table, not to the sheet — a decision column, a comment column.
  3. Give them a surface that writes. A dashboard over the table, or a form when the person is not signed in to Plank. Both save straight into the rows.
  4. Read the answers from the table, not from a file.

If it is a handful of rows, just ask in the chat — that is writable too, and cheaper than building a page.

Exporting a sheet afterwards, for someone to keep or send on, is fine and encouraged. The rule is only about direction: a workbook carries answers out, never in.

How this connects to HTML dashboards

App data is what makes a Plank HTML dashboard feel alive. A dashboard is an HTML file in your workspace; on its own it's just a page. Pointed at app data, it can fetch the current rows and render them — a live view of your leads, your tasks, your numbers — that stays in step with the data behind it.

Pair this with interactive HTML dashboards: a dashboard can show data and carry buttons that run a workspace script. A "Sync" button refreshes the underlying data; the dashboard then re-reads its tables and updates what you see. Stored data is the memory; the dashboard is the window onto it.

Multi-page dashboards

A dashboard doesn't have to be one page. A common shape is an overview that lists records and a detail page for one record — click a client in the list, land on that client's page, click back to return.

To build this, the assistant keeps each page as its own HTML file in the same folder and links them with ordinary links, for example a row that links to client-detail.html?id=.... Plank navigates in place: the detail page opens inside the same view, as if it were one app, with a back button to return to the list. Closing and reopening the file starts again at the first page.

Under the hood, each page reads its query string with plank.params (e.g. plank.params.id) and loads the matching row with fetch('apps/<table>/' + plank.params.id). Call plank.back() to add a back link to the previous page. Keep every page of one app in the same folder (v1).

You don't set any of this up yourself — you ask for it ("let me click a client to see their details") and the assistant wires the pages together.

Storing a list or object inside a row (JSON)

Most columns hold a single value — a name, a number, a date. Sometimes a record naturally carries a small structured value: a deal's activity log ("called on the 3rd", "emailed on the 5th"), a set of tags, or a bag of loose attributes that don't each deserve their own column. Plank supports a JSON column for exactly this — one column that holds a list or a nested object, saved and read back as the same shape (an app can store ["called", "emailed"] and read the same array back).

Good uses:

  • An activity log or timeline kept right alongside the record it belongs to — "show this deal's recent touches."
  • Tags or labels — a short list that travels with the row.
  • Flexible or sparse attributes that differ row to row and you only ever read back as a whole.

When not to use it — reach for a separate table instead:

  • You need to filter, sort, or total by those values ("deals with more than 3 activities", "sum of every line-item amount"). A JSON value is stored as one lump; the database can't efficiently slice inside it. Give each item its own row in a linked table.
  • The list is really its own kind of record with a life of its own — line items, contacts, tasks — that you'll add, edit, and delete individually. That's a one-to-many relationship: a child table linked back to the parent, not a JSON blob.

Rule of thumb: read it back as a whole → a JSON column is fine; you need to query into its parts → a separate table. You don't pick the column type yourself — ask the agent ("keep an activity log on each deal") and it chooses the right shape.

When to ask for app data

Reach for a data-backed app when the work is about keeping track of things over time rather than producing a single finished file:

  • A tracker or simple CRM (leads, deals, tickets, applicants).
  • A dashboard that should reflect the latest numbers each time you open it.
  • Anything where you'd otherwise re-paste the same list into chat to "remind" the agent.

If you just want a one-time document, a regular file is the better fit. See How Plank delivers finished documents for what those documents look like and how to change the format. If you want something that remembers, ask for an app.