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.

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

MethodPathKeyDoes
GET/api/dashboards—Every dashboard, as index summaries.
POST/api/dashboardsyesPublish or replace. The slug in the body decides which.
GET/api/dashboards/{slug}—One dashboard, in full.
PUT/api/dashboards/{slug}yesReplace, with the slug in the URL. 409 on a mismatch.
DELETE/api/dashboards/{slug}yesRemove it.
POST/api/dashboards/{slug}/edityesApply operations. The way to change a large document.
POST/api/dashboards/importyesPublish from a URL, remembering the source.
POST/api/dashboards/{slug}/refreshyesRe-pull from that source. 409 if there is none.
GET/api/dashboards/{slug}/accessyesWho may open it: visibility, owner, allow-list.
PUT/api/dashboards/{slug}/accessyesReplace 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"}
  ]}'
OperationAddressesDoes
setBlockblockId, or section + blockReplace one block.
addBlocksectionId or section, optional atInsert a block; appends by default.
removeBlockblockId, or section + blockRemove one block.
setSectionsectionId or sectionReplace a whole section.
addSectionoptional atInsert a section.
removeSectionsectionId or sectionRemove a section.
merge—Shallow-merge top-level fields. Not sections, not slug.
replaceTextfind, replace, optional allLiteral find/replace across every string.
  • Give things ids when you publish. An op addressing blockId survives 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.
  • merge will not take sections or slug. Changing sections is what the section ops are for, and the slug is the identity.
  • replaceText is 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. grid is a control panel — a 12-column track, blocks side by side. report is 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. meta for the range and counts, generatedAt for when the data is from, footer for how it was produced. A figure with no provenance is a figure nobody can act on.
  • Bind an agent. kortix.agent decides 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.