The Manifest
An OpenIslands app is a typed manifest — a JSON declaration of reusable visual islands bound to typed data contracts built from files you own.
An OpenIslands app is a typed manifest: a JSON declaration of reusable visual islands bound to typed data contracts built from files you own. You edit the manifest. You never write rendering code; the runtime owns that. The payoff is durability: a manifest is small, declarative, and validated, so an agent can maintain it for months without the dashboard rotting.
The skeleton
Every manifest has the same spine: a version, a title, the datasets it reads, and the pages that render them.
{
"version": 1,
"title": "Finance Overview",
"icon": "wallet", // optional, the app's tile icon
"datasets": {
"net_worth": { "source": "data/net_worth_monthly.csv" } // a file you own (see Data Contracts)
},
"pages": [
{
"id": "overview", // one sidebar entry per page
"icon": "house",
"islands": [
{
"type": "metric.kpi",
"title": "Net worth",
"dataset": "net_worth", // names a dataset above
"value": "net_worth_eur", // a field that must exist in it
"compareTo": "prev",
"format": "eur",
"span": 3
}
]
}
]
}That single KPI island, fed its data, renders exactly like this. The docs run the real renderer, not a screenshot:
Pages and groups
Each page is one entry in the left sidebar (a single-page app stays chrome-free). A page
holds either a flat list of islands or tabbed groups, never both. Groups render
as tabs under the page header, deep-linked via ?group=<id>:
{
"id": "holdings",
"groups": [
{ "id": "positions", "title": "Positions", "islands": [ /* ... */ ] },
{ "id": "activity", "title": "Activity", "islands": [ /* ... */ ] }
]
}A page may also declare shared filters rendered in the page header — a daterange over a
date column (optionally opening on a default period like last-90-days, resolved live against
today), or a select that narrows a categorical column (single or multiple, its choices
drawn from the column's live distinct values) — each re-querying every island bound to the
filtered column at once. A filter only touches the datasets named in its bind map; an island
whose dataset isn't bound (e.g. a SQL transform you add to a filtered page later) keeps showing
all-time until you add it to bind. See the Manifest Reference for the
full shape.
Spans and the grid
Every page (and every group) is a 12-column grid. An island's span is how many columns it
takes, from 1 to 12. Each island type carries three bounds:
- A minimum span below which it stops being legible — a
table.gridneeds 5, most charts need 4, ametric.kpineeds 2. Below it,validaterejects the manifest with a named error like "span 1 is below the minimum 4 for timeseries.line." - A maximum span above which it only stretches into dead space — and the over-max error is the mirror of the under-min one: "span 12 exceeds the maximum 6 for funnel.steps — it only stretches into empty space past that width." Compact, single-value islands (KPIs, funnels, gauges, pies, radars) are capped well below full width; data-dense islands (tables, charts, feeds, calendars) run the full 12.
- A recommended span in between — what the island renders at when you omit
span. Afunnel.steps, for instance, is min 3, recommended 4, max 6.
validate (and the MCP edit path) also runs an advisory layout linter that emits
non-blocking warnings for composition smells — a lone metric.kpi (group them or use
metric.scorecard), or a compact island stretched past its recommended width. The dashboard
still renders; the warning just nudges you toward a tidier layout.
The schema is the source of truth for all three numbers: see ISLAND_MIN_SPAN,
ISLAND_DEFAULT_SPAN, and ISLAND_MAX_SPAN in packages/schema/src/index.ts, or read an
island's range straight from oi.app().getIslandSchema(type) over the MCP. The runtime also floors
spans responsively, so a tile never renders narrower than its usable width on a small screen.
Fail loudly
The manifest's job is to make a broken dashboard impossible to ship silently. Every
island binds to named fields, and validate (and the agent's oi.app().replaceManifest) checks
each one against the live, DuckDB-inferred columns of the data:
- Bind to a field that exists → the island renders.
- Bind to a field that doesn't → the build fails and names the page, the island, and the missing field. You never get a silently-wrong chart pointed at a column that isn't there.
That check is the safety net the whole design rests on. Keep the manifest declarative, with no data transforms inside island configs (shaping lives in the data layer), and the net stays taut.
Where to go next
- Data Contracts: datasets, SQL transforms, and the binding check in detail.
- Islands: every built-in island and its required fields, with live previews.
- Manifest Reference: the exhaustive, schema-generated field list.
Self-hosting
Run OpenIslands as an always-on Docker container on a server or home NAS — the dashboard and the MCP server over HTTP in one process, from the ghcr image, with a token guarding the write surface off loopback.
Data Contracts
An island binds to a dataset — a named, typed contract resolved through a DuckDB query core that checks every binding against your live data.