---
title: Build a dashboard from a file
description: Upload a CSV or Excel file through your AI tool, author against the catalog it produces, and publish a dashboard that reads that one file.
updated: 2026-09-20
tier: pro
---

Not every set of numbers lives in a warehouse. If yours is a CSV or an `.xlsx`
workbook on your disk, you can hand that file to Dashies once and build a
dashboard on it, with the same spec, the same tiles and the same versioning as a
warehouse dashboard.

The file goes from your disk straight to Dashies. It is not read into your
conversation with your AI tool, and it is not pasted into a spec.

## Before you start

| You need | Why |
|---|---|
| A paid plan or a trial on the workspace | Uploading is gated the same way connecting a warehouse is. See [Plans and what is gated](/concepts/plans-and-gating#what-is-gated). |
| A creator seat | A viewer seat may read the workspace's dashboards and may not add data to it. |
| A free connection slot, for the **first** upload only | A workspace's uploads share one data connection, created by the first upload and spending one of the workspace's slots. Later uploads reuse it and need no slot. See [Connections and scope](/concepts/connections-and-scope). |
| An AI client that can run a shell command | The bytes are sent by a `curl` line your AI tool runs. |

:::warning{title="A client with no shell cannot upload"}
The upload is a one-time URL plus a `curl` command, run by the agent in its own
shell. That is what keeps the file out of the conversation, and it is also the
one requirement that cannot be worked around today: an AI client with no shell
cannot take this path at all.

**There is no upload form in the Dashies web app**, on any plan. The app has no
authoring surface of any kind; a dashboard, and now a file, arrives through your
AI tool.
:::

## What happens to the file

:::steps

1. **Your AI calls
   [`create_file_upload`](/reference/mcp-tools/create-file-upload).**

   It passes the filename and, worth doing, the exact byte size. Nothing is sent
   yet. The answer is a one-time URL and the exact `curl` command to run. The
   ticket in it works once and expires; the response prints the exact time.

2. **Your AI runs the command it was given.**

   The file is `PUT` to Dashies. Only a few uploads may be in progress in one
   workspace at a time; if you are over that, the refusal names the number.

3. **Dashies converts the file, once.**

   A CSV becomes one table. A workbook becomes one table per **visible worksheet
   that has rows**: hidden sheets, chart sheets and empty ones are skipped, each
   with a warning in the catalog saying which and why. Name a hidden sheet in the
   `sheets` override to read it anyway.

   Each column's type is decided by reading the **whole** file rather than a
   sample, and the verdict is written down as a **catalog**.

4. **Your AI polls
   [`get_file_upload`](/reference/mcp-tools/get-file-upload) until the status is
   `ready` or `failed`.**

   A `ready` answer carries the catalog: every table, every column, its declared
   type, and the findings that kept a column as text. It also carries the
   SHA-256 of the bytes Dashies stored, which you can compare against
   `shasum -a 256` on your own copy.

5. **Your AI authors against the upload.**

   [`introspect_schema`](/reference/mcp-tools/introspect-schema),
   [`explore_data`](/reference/mcp-tools/explore-data) and
   [`validate_cube_sql`](/reference/mcp-tools/validate-cube-sql) all take the
   uploaded-file connection as `connection` and the upload id as `upload`.

6. **Your AI publishes.**

   The spec's `source.connection` is the uploaded-file connection and
   `source.upload` is the upload id. See
   [Source](/reference/spec#source).

:::

## What the SQL looks like

:::warning{title="The statement returns records, not a summary"}
**Your statement returns one row per underlying record**, carrying the columns
each number is worked out from, and each number is declared in the spec rather
than worked out in the SQL. Dashies holds those rows and works the numbers out
when a reader opens the page, so a statement that has already aggregated gets
aggregated a second time.

**Nothing refuses the wrong shape.** A plain total survives the wrong grain, so a
statement that groups reads as correct until somebody adds a count, and then the
count is the number of groups you made rather than the number of records.

This is the **held shape**, the one a statement takes against a warehouse
connection or the workspace's uploaded-file source, and an uploaded file is
always on that side. So the four in-file modes on
[Datasets and the four modes](/concepts/dataset-modes) are not a choice here,
and a publish that asks for one is refused.
[Designing the cube](/concepts/designing-the-cube) has the shape's rules in full,
and
[A warehouse dashboard is served](/guides/connect-warehouse#a-warehouse-dashboard-is-served)
states it on the warehouse side.
:::

Your SQL runs against the tables the conversion made, so two things differ from
every page under [Connect a warehouse](/guides/connect-warehouse). **Neither of
them is the shape.**

**The table name comes from the file, not from you.** A CSV's table is named
after the filename without its extension, folded down to `a-z0-9_`: `Q3 Sales.csv`
is queried as `q3_sales`. A workbook's tables are named from the sheet names the
same way, and because two sheet names can fold to one table name, a collision
gets a numeric suffix.

The folding has more rules than are worth carrying in your head: accents fold to
the base letter rather than to `_`, runs of punctuation collapse to a single `_`,
a name starting with a digit gains a `t_` prefix, and a long one is truncated. So
**the catalog is the authority** - read the table name out of `get_file_upload`
rather than deriving it here.

**Column names are kept exactly as the header spelled them**, spaces and capitals
included, so quote them and alias to the key grammar the spec wants:

```sql
select
  "Order Date"   as order_date,
  "Region"       as region,
  "Net Amount"   as net_amount
from q3_sales
```

**That statement has no `GROUP BY` and no aggregate**, and that is the record
grain the shape asks for rather than a simplification made for the example.
`net_amount` arrives as the column it is on each record, and the spec is where
you say to total it.

Four things about a header row are refused at conversion, each with a sentence
naming the column's position: a **blank** header, an **over-long** one, a
**duplicate**, and two headers that differ **only in capitals**. That last one is
the exception to headers being kept verbatim: your capitals are preserved, but
`Region` and `region` in one file cannot be told apart once quoted, so the file
is refused rather than guessed at.

## Changing the data means a new upload

**An upload is immutable, and nothing replaces a file in place.** There is no
"update this file" call, and re-running the `curl` command against a spent
ticket does not work.

To publish newer numbers: create a new upload, send the new file, and republish
the spec with the new id in `source.upload`.

:::warning{title="Dashies never picks the newest upload for you"}
`source.upload` is required on a file source and a publish without it is
refused. That is deliberate: if the newest upload won by default, republishing a
byte-identical spec would move the numbers on the page with nothing in the
document saying why, and no diff anywhere would show it. Naming the upload makes
a data change a document change.
:::

If you are arriving from Tableau, this is the rule its own web upload works by
as well: a file uploaded by hand is not refreshed for you, and new data means
uploading a new version of the file.

### A schedule does not fetch a new file

A cadence other than `manual` is **accepted** on a file source, with a warning,
rather than refused. It is worth knowing exactly what it does: a scheduled
refresh re-runs your SQL over the **same** converted file. That only changes the
numbers if the SQL is time-relative, such as a trailing window or anything
reading `current_date`. It cannot pick up a new file.

`schedule: manual` is the honest default here.

## Getting the read wrong, and fixing it without re-uploading

If the conversion typed a column in a way you did not want, or read the CSV with
the wrong delimiter, encoding or header row, you do **not** upload the file
again. Pass `reconvert_of` with the original upload id and an `overrides` object
to [`create_file_upload`](/reference/mcp-tools/create-file-upload): the same
stored bytes are read again under the new instructions, and the original upload
is left untouched.

Each override is checked against a census of the same stored bytes read under
your new instructions, so an override the data does not support fails that
upload and names your value instead of silently dropping rows.

**The window for this is bounded.** A failed upload's stored bytes are kept for a
while and then dropped, and re-converting works only while they are there; after
that you upload the file again. Reading a failed upload's status does not extend
that window.

## What is refused

| Refused | What you get |
|---|---|
| A legacy `.xls` workbook, or a password-protected workbook | `the file is a legacy .xls workbook or a password-protected workbook. Save it as .xlsx without a password.` |
| Another Office file, such as `.docx` or `.xlsb` | a sentence saying it is not an `.xlsx`, and to save it as one |
| A file that is not a spreadsheet at all, such as a PDF or an image | **not** a sentence about the format. See the note below. |
| A file over the size cap | refused before anything is sent when the upload declares its size, and at the `PUT` otherwise. See [Limits](/reference/limits#size-constants). |
| A workspace with no paid plan or trial | `A paid plan is required to upload a file. Uploaded files are available on paid plans and during a trial.` |
| A viewer seat | `You hold a view-only role in this workspace, so you cannot upload files to it. A workspace admin can change your role.` |
| A workspace with no connection slot left | a sentence naming the data-connection limit |

A **failed** upload is not the end of it, and the sentence beside the failure token
decides what to do. Where it names an override, re-convert the same stored bytes
with `overrides`. Where the file itself is wrong, fix it and create a new upload.

:::note{title="Sending the wrong file does not get you a sentence about formats"}
Dashies decides what a file is from its **first bytes**, never from its name or
its content type. Only two shapes are recognised: a zip, which is where an
`.xlsx` lives, and a legacy Office container. Anything else is attempted as
**text**.

So a PDF or an image is not refused as a PDF or an image. It is read as text,
fails on the first byte that text cannot contain, and comes back saying the file
is not valid UTF-8 or carries a NUL byte, usually suggesting an encoding
override. That advice is right for a mis-encoded CSV and useless for a PDF, so
**read the filename you sent before you reach for an override**.

A file that genuinely is text but is not tabular, such as JSON or a log, is not
refused at all for being the wrong kind of thing. It either fails on columns or
rows, or converts into a table that is real and meaningless. Check the catalog
`get_file_upload` returns before you build on it.
:::

:::note{title="The size cap is smaller than a file-import BI tool's, deliberately"}
`FILE_UPLOAD_MAX_BYTES` on [Limits](/reference/limits#size-constants) is the
whole of it: there is no multipart upload and no chunking, so a file over the
cap cannot be sent in pieces. Splitting the data across several files works, and
each one is its own upload.

Dashies does not claim parity here with a BI tool whose headline feature is
importing a large file. Data past this cap belongs in a
[warehouse connection](/guides/connect-warehouse), which has no source-file
ceiling at all.
:::

## How long an upload is kept

**Ordinarily a reference keeps an upload alive.** A converted upload that a live
dashboard reads, or that a retained version names, is not collected while that
reference exists. One that nothing references is removed after a grace period
during which nothing has referenced or used it, so the copies left behind by
iterating on a file do not accumulate forever.

That is why the version-retention rules matter here in a way they do not for a
warehouse dashboard: pruning an old version can be what releases the last
reference to an old upload. See [Version history](/guides/version-history).

A **failed** upload is outside that rule: it is dropped on a shorter clock of its
own whether or not anything names it. That costs you nothing, because a failed
upload cannot be published in the first place.

:::danger{title="Deleting the connection deletes the files, references and all"}
**On every other data source, deleting the connection withdraws a credential and
the dashboards built on it keep their numbers. Here the connection is the data.**
Deleting the workspace's `Uploaded files` connection moves every upload under it
to deleted, the ones live dashboards are reading included, and nothing refuses or
warns first.

The web app cannot do this, because the uploaded-file connection is not on the
**Data sources** page and there is nothing there to press. What can do it is a
tool call or a script naming the connection id, which is the same id
`list_connections` reports beside your uploads. **Treat that id as the
workspace's spreadsheet data rather than as a connection you can drop and
re-add.**

There is no undo. Recovering means uploading each file again and republishing
every dashboard that read it against the new upload.
:::

A workspace whose plan has ended has its uploaded files removed along with the
rest of its stored data. That one is not silent: you are told before anything
goes and the notice names the date. See
[Plans and what is gated](/concepts/plans-and-gating#what-a-lapsed-plan-actually-does).

## Check it worked

Ask your AI tool for the workspace's connections. The uploaded-file row is
labelled `Uploaded files`, and it is the only row that carries an `uploads`
block: your recent uploads, each with its id, filename and status.

**Ask your AI rather than the web app, because the app does not show this
connection at all.** It is hidden from the **Data sources** page, which lists
the connections you create there. That is the app being consistent about having
no upload surface rather than anything being wrong; see
[Connections and scope](/concepts/connections-and-scope).

Then open the published dashboard and compare one tile against the same figure
in the spreadsheet.

## Next

[Author a dashboard](/guides/author-a-dashboard) for the rest of the authoring
loop, or [Connections and scope](/concepts/connections-and-scope) for how the
uploaded-file connection sits beside your warehouse ones.
