# 0036 · A page can be a dashboard, and an automation keeps its numbers current

**Status:** Accepted · 2026-11-06. It adds a kind of page file to 0015, a way
for `actions.write` to change part of a page ([0026](https://codna.me/docs/adr/0026-scripts-ask-a-model.md)), and a tool for
AI tools to publish with. Pages keep the permission model they have: there is still no anonymous
access.

## Context

Claude makes dashboards: charts, numbers and tables of data, drawn by a page of HTML it writes.
People like them, and want to share them with their team. A dashboard made in claude.ai lives in
claude.ai, and one made in Claude Code is a file on someone's laptop. Neither is where the rest of
the team's knowledge is, and neither can be shared with the people CoDNA already knows.

A claude.ai dashboard is three things, not one:

- **The HTML Claude writes.** It holds no numbers, only places marked for them.
- **Its data sources.** Each is either a query on one of the viewer's connectors, run again
  whenever the page is opened and with that viewer's own access, or a CSV or JSON file attached to
  it.
- **claude.ai's code**, which runs the queries, fills the places and says where each number came
  from.

Copying the HTML out leaves a page of dashes. Claude's own instructions for exporting a dashboard
say to rebuild it in the other product with that product's connector. CoDNA's connector is
already in those AI tools. What is missing is a way to rebuild a dashboard there, and to keep its
data current once it is there.

CoDNA has most of the parts. A page has an address, permissions, a history, a trash and an export.
A page holds files that are served as hostile bytes (0015). Automations run on a schedule, read
APIs and databases through their owner's connections (0025), and write pages in `output` (0026).
A customer's daily campaign report is already an automation that writes a page every morning from
the numbers it reads. It only lacks the drawing.

## Decision

**A dashboard is a page.** Its markdown says what it shows and holds its data. The drawing is an
HTML file of the page, shown where the page shows it. Sharing, folders, history, search, the trash
and export are the page's, with no change.

````markdown
Spend and clicks for each campaign over the last 30 days, from the campaign database.

![Campaigns](campaigns.files/dashboard.html)

## Data

```json stats
[
{"day":"2026-11-05","campaign":"Autumn","spend":120.5,"clicks":3400},
{"day":"2026-11-05","campaign":"Welcome","spend":80,"clicks":2100}
]
```
````

### The drawing is a page file

The HTML is a file of the page, `<slug>.files/dashboard.html`, under 0015's rules: it belongs to
the page, it has the page's permission, it never changes, and it is kept for as long as the page
is. Publishing a different drawing adds `dashboard-2.html` and points the page at it, so every
version in the history still shows the drawing it had.

**The page shows it with an image's markdown.** `![Campaigns](campaigns.files/dashboard.html)` on
a line of its own is where the dashboard is drawn, as wide as the page's column. Image syntax
because that is what markdown has for "show this here". Anywhere else (GitHub, an exported file)
it is a broken image or a link, and the page's text still says what the dashboard showed.

People do not upload HTML, and the editor's file upload still refuses it. Only publishing (below)
adds one. A drawing is at most 1 MB.

### The data is in the page

Each **dataset** is a fenced block whose info string is `json` and the dataset's name: letters,
digits, `-` and `_`, up to 64. Its body is one JSON value: rows as an array of objects, or any
other JSON. A drawing reads its page's datasets by name and nothing else.

Keeping the data in the markdown is what makes a dashboard a CoDNA page and not a picture of one:

- **AI tools read the numbers.** `get_document` returns them with the rest of the page, so an
  agent asked about last week's spend answers from the dashboard's own data.
- **The history is the data's history.** Each refresh is a version, and a diff shows which rows
  changed. An array is written one row per line for that reason.
- **People can correct it.** A number fixed by hand in the editor is the number the dashboard draws.

The cost is size. Every version is kept, so a dataset should be what the dashboard shows (30 days
of daily totals), not the raw rows it was worked out from. A page written by a script is at most
256 KB, as before.

### Publishing

`publish_dashboard` over MCP, and `POST /api/dashboards` for anything with a token, take a page's
address, its title, a description, the HTML and the datasets.

- **A new page** needs all of them but the datasets. The page is made as the person, private
  until shared, like any other.
- **An existing page** takes a new version, as an edit by the person. With a `description`, the
  page is written whole: the description, the drawing, then `## Data` and the datasets. Without
  one, only the parts given change, and the page's own text stays: the drawing's line points at
  the new file, a given dataset replaces the block of that name or is added at the end, and `null`
  takes one out. A dataset not given keeps the block the page has, so republishing the drawing
  never throws away numbers an automation wrote.

### An automation keeps it current

`actions.write` takes `data` in place of `content`:

```js
actions.write({ into: 'output:dashboards/campaigns', data: { stats: inputs.stats.rows } })
```

It changes the named datasets of the page at `into` and leaves the rest of the page as it is:
the description, the drawing, any dataset not named. The page must already be there, and in
`output`, as every page a script writes is. Writing what the page already holds makes no version.

**A version records the automation that wrote it** (`document_revisions.automation_id`), not only
that an automation did. That is how the dashboard knows what refreshes it:

- Everyone who can read the page sees when it was last refreshed, and by which automation: the
  later of the last version the automation wrote and its last run that went through.
- **The automation's owner** also sees **Refresh now**, which runs a scheduled automation as a
  tick, and, when its last run failed, that it failed and why, with a link to the automation.
  Automations are their owner's, so nobody else is offered either.

The numbers come through the automation owner's connection, so everyone who can read the page sees
the same numbers. A dashboard whose numbers depend on who is looking is not in this version (see
Deferred).

### Serving the drawing

The drawing is HTML and script that someone else wrote, shown to people signed in to CoDNA. It is
served as hostile code.

`GET /api/dashboards/<space>/<path>/<slug>.files/<name>.html` answers with the file and its page's
datasets put into it: a `<script type="application/json">` with the data, escaped so it cannot
close its element, and a short script that provides `codna.data(name)`. The route is in the
`content` class, and a page the reader cannot read is the same 404 as one that is not there, as
with every file. The file's own route still serves it as a download, under 0015's headers.

Its `Content-Security-Policy`:

```
sandbox allow-scripts allow-popups allow-popups-to-escape-sandbox allow-downloads;
default-src 'none';
script-src 'unsafe-inline' 'unsafe-eval' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://unpkg.com;
style-src 'unsafe-inline' https://fonts.googleapis.com https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://unpkg.com;
font-src https://fonts.gstatic.com data:;
img-src data: blob:;
connect-src 'none'; form-action 'none'; base-uri 'none'; frame-ancestors 'self'
```

- **`sandbox` without `allow-same-origin`.** The drawing runs in an origin of its own that matches
  nothing. It cannot read CoDNA's cookies or storage, its requests to CoDNA's API carry no session,
  and it cannot reach into the page around it. That holds whatever opens its address, because the
  header goes with the response. The page shows it in an `<iframe>` with the same sandbox, which
  also keeps it from navigating the app or opening dialogs.
- **Only into a frame.** A browser opening the address as a page of its own
  (`Sec-Fetch-Dest: document`) is refused. A drawing on its own could be made to look like
  anything, a sign-in form for one, at an address on the instance and with nothing of CoDNA around
  it. In its page it sits in the column, under the page's title. Links in it still open in a new
  tab: an author could link anywhere from a page already.
- **No network but libraries.** Scripts and styles may come inline or from three CDNs that serve
  npm packages at pinned versions, and fonts from Google Fonts. There is no `connect-src` and no
  outside image, so a drawing cannot fetch anything, send anything, or count who opens it. What a
  dashboard shows is what its page holds. `Referrer-Policy: no-referrer`, so a library's request
  does not carry the page's address to the CDN. Inline script and eval are allowed because the
  drawing is its own code: in an origin that reaches nothing, eval can do no more than a script.
- **What it could do anyway.** A drawing could encode its page's data in a library URL it asks a
  CDN for. Only the CDN would see it. And whoever wrote the drawing could edit the page, so they
  could read that data already. The drawing sees only its own page, so it is never handed anything
  its author could not read.
- **Same host.** It is served by the instance's own host, not a separate content domain such as
  GitHub's `githubusercontent.com`. The sandbox is what isolates it, and a second domain would
  need DNS, certificates and a session of its own for every instance.

**A drawing is as tall as what it draws.** The script CoDNA adds tells the page its height as it
changes, and the page sizes the frame to it. It also sets `codna.theme`, and CSS variables in
CoDNA's colours (`--codna-fg`, `--codna-bg`, `--codna-series-1` to `-8` and others), for light or
dark as the page is shown. Links open in a new tab.

### What AI tools are told

`publish_dashboard` describes the contract to the model: the data is read with `codna.data`, the
network is closed but for the CDNs, it draws to its width, and it is as tall as its content. Claude
rebuilding a claude.ai dashboard turns each live query into a dataset the automation refreshes,
and each attached file into a dataset of its rows. `get_file` hands an agent a drawing's HTML as
text, so it can change one and publish it again.

## Consequences

- A dashboard made by Claude can be shared like any page, kept in a folder, found by search, and
  read by every AI tool connected to CoDNA.
- An automation that already writes a report can keep a dashboard's numbers current with a
  one-line change.
- Every refresh is a version, kept forever. Large datasets refreshed often make large histories.
- A drawing loads libraries from public CDNs, so a dashboard needs those CDNs to be reachable from
  the viewer's browser.
- A page written by an automation now says which automation wrote it. The history can link to it.

## Deferred

- **Numbers that depend on the viewer**, as claude.ai's live queries do. A CoDNA connection is one
  person's, so it would need queries that run as the viewer, or a rule for whose data a shared
  dashboard may show.
- **Reading other pages.** A drawing reads only its own page. Reading others the viewer can read
  would let the author of a drawing read what only the viewer can. It needs a rule first, for
  example that the author can read them too.
- **Public links.** No page is shown to anyone not signed in, dashboards included.
- **Uploading a drawing in the web UI**, and drawing one there.
