Reference
Publishing dashboards
A dashboard is one JSON document. This app renders it with a shared block library, so publishing means POSTing a spec and changing one means sending the parts that changed. There is no per-dashboard code and nothing to deploy.
Every block rendered live, with the JSON that produced it.
The whole API, for a client generator or a viewer.
Just the spec, generated from the validator the API enforces.
Getting started
Reads are open. Writes need the API key, sent as either Authorization: Bearer <key> or X-API-Key: <key>. If the server has no key configured it accepts writes from anyone and says so on the index page, which is the right default on a laptop and the wrong one on a deployment.
curl -sS "$DASHBOARDS_URL/api/dashboards" curl -sS -X POST "$DASHBOARDS_URL/api/dashboards" \ -H "Authorization: Bearer $DASHBOARDS_API_KEY" \ -H 'content-type: application/json' \ --data-binary @dashboard.json
Write the spec to a file and use --data-binary @file. Inlining a large document into a shell argument truncates it and mangles the quoting.
The smallest dashboard
{
"slug": "hello",
"title": "Hello",
"sections": [
{ "blocks": [ { "type": "bignumber", "value": 42, "label": "Answers" } ] }
]
}slug is the identity, the URL and the filename: lowercase letters, digits and single hyphens. Posting the same slug again replaces that dashboard. A weekly report keeps one slug forever so the link people bookmarked keeps working — never report-2026-08-14.
Endpoints
| Method | Path | Key | Does |
|---|---|---|---|
| GET | /api/dashboards | — | Every dashboard, as index summaries. |
| POST | /api/dashboards | yes | Publish or replace. The slug in the body decides which. |
| GET | /api/dashboards/{slug} | — | One dashboard, in full. |
| PUT | /api/dashboards/{slug} | yes | Replace, with the slug in the URL. 409 on a mismatch. |
| DELETE | /api/dashboards/{slug} | yes | Remove it. |
| POST | /api/dashboards/{slug}/edit | yes | Apply operations. The way to change a large document. |
| POST | /api/dashboards/import | yes | Publish from a URL, remembering the source. |
| POST | /api/dashboards/{slug}/refresh | yes | Re-pull from that source. 409 if there is none. |
| GET | /api/dashboards/{slug}/access | yes | Who may open it: visibility, owner, allow-list. |
| PUT | /api/dashboards/{slug}/access | yes | Replace that. `restricted` needs one grant. |
| GET | /api/schema | — | JSON Schema for the spec, generated from the validator. |
| GET | /api/openapi.json | — | OpenAPI 3.1 description of everything above. |
The same table, machine-readable and with every request and response schema attached, is at /api/openapi.json — an OpenAPI 3.1 document generated from the validators themselves, so it describes what the API enforces rather than what someone remembered about it. Point a viewer, a client generator or an agent at it.
Editing without rewriting
These documents run to tens of kilobytes. Re-emitting one to change a single value wastes a turn and, worse, loses data: a model reproducing a large table from memory drops rows and rounds figures. Send operations instead.
curl -sS -X POST "$DASHBOARDS_URL/api/dashboards/ripple-sentiment/edit" \
-H "Authorization: Bearer $DASHBOARDS_API_KEY" \
-H 'content-type: application/json' \
-d '{"ops":[
{"op":"merge","value":{"tone":"danger"}},
{"op":"setBlock","blockId":"score-banner","value":{
"type":"banner","id":"score-banner","tone":"danger",
"headline":"SCORE: RED — AT RISK"}},
{"op":"replaceText","find":"16 days open","replace":"17 days open"}
]}'| Operation | Addresses | Does |
|---|---|---|
| setBlock | blockId, or section + block | Replace one block. |
| addBlock | sectionId or section, optional at | Insert a block; appends by default. |
| removeBlock | blockId, or section + block | Remove one block. |
| setSection | sectionId or section | Replace a whole section. |
| addSection | optional at | Insert a section. |
| removeSection | sectionId or section | Remove a section. |
| merge | — | Shallow-merge top-level fields. Not sections, not slug. |
| replaceText | find, replace, optional all | Literal find/replace across every string. |
- Give things ids when you publish. An op addressing
blockIdsurvives a block being inserted above it; a numeric index does not. Ops also run in order against the document as the previous op left it, so indexes shift within a single request too. - Nothing half-applies. The whole document is re-validated after the ops run. If the result would be invalid, the stored dashboard is untouched and the response lists the problems.
mergewill not takesectionsorslug. Changing sections is what the section ops are for, and the slug is the identity.replaceTextis literal, not a regular expression and replaces everywhere by default. Read the current value first — a string that appears twice changes twice.- The response returns the full updated document, so a verifying GET afterwards is redundant.
Round-tripping
A GET returns createdAt, updatedAt and sourceUrl alongside the spec. The schema rejects unknown keys, so posting a fetched document back unmodified fails validation. Strip them first — this is the most common way a round trip breaks.
curl -sS "$DASHBOARDS_URL/api/dashboards/ripple-sentiment" \ | jq '.dashboard | del(.createdAt, .updatedAt, .sourceUrl)' > dashboard.json
Errors
- 400
- The request was malformed, or an operation could not be applied — a bad JSON body, an unresolvable id, an index out of range, an unknown op.
- 401
- A write without the API key, or with the wrong one.
- 404
- No dashboard with that slug.
- 409
- The URL slug and the body slug disagree, or a refresh on a dashboard that was never imported.
- 422
- The document is not a valid spec. The body carries an `issues` array of `path: message` strings. Fix the field it names.
A 422 is worth reading rather than retrying. Its issues are paths into your document — sections.0.blocks.2.tone: Required names the exact field. Fix that field; do not re-send the same body.
Writing a good dashboard
- Tones, never colours. A spec says
danger, not a hex value. The theme owns the palette, which is what keeps every dashboard consistent and makes dark mode and the PDF export work without you thinking about either. - Pick the layout honestly.
gridis a control panel — a 12-column track, blocks side by side.reportis a document — one column, numbered sections, read top to bottom. A weekly written report is a report; a board of live numbers is a grid. - Put the verdict first. A banner at the top saying what the reader should conclude, then the numbers that justify it, then the detail. Nobody scrolls to find out whether things are fine.
- Fill in `highlights`. They are what the index card shows, and they are the difference between a grid people scan and a grid people have to open one by one.
- Say where the numbers came from.
metafor the range and counts,generatedAtfor when the data is from,footerfor how it was produced. A figure with no provenance is a figure nobody can act on. - Bind an agent.
kortix.agentdecides who answers when someone asks a question about the dashboard. Point it at the agent that produced it, and the conversation starts with the context already loaded.
Exporting
Every dashboard has a print document at /d/{slug}/print, laid out at real A4 geometry rather than at browser width. Orientation follows the layout — a grid exports landscape, a report exports portrait — and ?orientation= overrides it. The Save as PDF button on a dashboard opens that route with ?auto=1, which prints once fonts, images and charts have settled.