Content & Layout
The islands that aren't charts of your data — a note for prose, a source doc for a file or link, a content editor for a workspace of markdown, a form for adding rows, and a layout row.
The islands that aren't charts of your data: a note for prose, a source doc for a file or link, a content editor for a whole workspace of markdown files, a form entry for adding rows by hand, and a layout row to control how a page wraps — plus tabbed groups to split a busy page. See the islands overview for the full registry.
note.card
What it is. A static Markdown card with no data binding. When to use it: commentary, a
caption, instructions, or a link, dropped right between data islands. It renders markdown:
headings, lists, inline code, bold/italic, and links all render. Set a tone to turn it into a
callout that stands out from the prose around it.
{
"type": "note.card",
"title": "How to read this page",
"markdown": "The top row tracks **net worth**..."
}Key options.
markdown: the only required field; nodataset. Use\n\nbetween blocks and\nfor a soft break inside one.tone: "info" | "success" | "warning" | "danger"(optional): renders the note as a callout — a tinted panel with a colored left accent and a matching icon. Omit it for plain prose.
Tones. Reach for a tone when the note is an aside the reader shouldn't miss — a caveat next to a metric, a heads-up about stale data, a confirmation. Without one, the note reads as quiet commentary.
{
"type": "note.card",
"title": "Heads up",
"tone": "warning",
"markdown": "Balances settle overnight, so **today's row updates after midnight UTC**."
}Tip
note.card is the lightest island, perfect for explaining a dashboard to whoever opens it next.
Its minimum span is 3, so it sits comfortably beside a metric or a small chart.
source.doc
What it is. A document card that surfaces a source right next to the data it backs: a PDF
statement, a Markdown brief, an image, or a plain link out. When to use it: when a reader will
want the underlying file. Every card shows a type icon, a readable name, and an Open button
that opens the source in a new tab. Set kind to choose how it renders, and give it either a
file (a path inside the project) or an href (an external URL).
{
"type": "source.doc",
"title": "Methodology",
"kind": "link",
"label": "OpenIslands on GitHub",
"description": "Source, issues, and the manifest schema",
"href": "https://github.com/lukaisailovic/openislands"
}Key options.
kind: "pdf" | "markdown" | "image" | "link"(defaultlink): how the source renders.pdfembeds the file in a viewer below the card header,markdownrenders it inline,imageshows a preview that opens full size,linkis an external link out.file: a path relative to the project root; it resolves through the runtime's confined file route.hrefis an external URL used as-is.label: the name shown on the card. Without it, the card falls back to the file's name or the link's host — never the raw file URL.description: a short line under the name explaining what the document is.
Note
A file-based doc needs a real file in the project (resolved through the runtime's /api/file
route), so it can't be previewed here in the docs. The live example above uses kind: "link"
with an href, which renders the same everywhere.
content.editor
What it is. A full-page, Obsidian-style content workspace. When to use it: when the page
is a body of documents — a knowledge base, a set of runbooks, a notebook — rather than a chart of
your data. It's the one island that binds no dataset and runs no SQL: it reads and writes
markdown files on disk directly. It renders full-bleed (no card header or title bar) and wants a
full-width span of 12; its minimum is 6.
Point it at either a single file or a whole dir — exactly one, never both:
{
"type": "content.editor",
"title": "Runbook",
"span": 12,
"file": "data/docs/oncall.md"
}{
"type": "content.editor",
"title": "Docs",
"span": 12,
"dir": "data",
"csv": true,
"groups": [
{ "id": "specs", "label": "Specs", "icon": "files", "match": ["specs/**", "architecture.md"] },
{ "id": "runbooks", "label": "Runbooks", "icon": "list-bullets", "match": ["runbooks/**", "incident-*.md"] },
{ "id": "notes", "label": "Notes", "icon": "folder", "match": ["notes/**", "roadmap.md"] }
]
}Key options.
file/dir: the source, and exactly one is required —fileis one document underdata/ordocs/,diris a directory (recursed). Both, or neither, is a named validation error.include(dironly): globs of files to surface; defaults to markdown (**/*.md,**/*.markdown).csv(dironly, defaultfalse): also surface.csvfiles alongside the markdown. CSVs open as an editable table — edit cells, add and delete rows, and Save writes valid CSV back to the file. They're read-only only whenreadOnlyis set. (Adding or removing columns is out of scope; edit the header in your own editor.)groups(dironly): virtual folders, each{ id, label?, icon?, match }. See below.readOnly(defaultfalse): make the whole workspace a viewer — no editing, no saving.
Setting include, csv, or groups together with file (rather than dir) is a named
validation error.
Editing and version history. Unless readOnly is set, edits autosave about a second after
you stop typing — no Save click needed, though Save and ⌘S still force one. The header shows a
Saving… / Saved hint as it goes. The file on disk stays the source of truth — change it in your
own editor and the open workspace refreshes within a second or two (unsaved edits in the browser are
never clobbered). Every save also records a restorable snapshot in a per-app SQLite store at
.openislands/editor.sqlite; the workspace surfaces that history and lets you roll a file back to
any earlier version, so edits are never lossy.
Virtual folders (groups). A dir is browsed as its real tree by default. groups overlay
virtual folders that gather scattered files into tidy sidebar buckets regardless of where they
live on disk: each group's match is an array of globs relative to dir, label names it (it
defaults to the id), and icon is a Phosphor name (e.g. files,
folder) that falls back to a folder icon. Files matching no group collect in an Ungrouped
bucket, so nothing is hidden. The New-note dialog picks the folder a note starts in, and you can
move a note between folders later — drag it onto a group or use its move menu — which renames it on
disk into that folder and carries its version history along.
Note
There's no live preview for content.editor here: it reads and writes real files through the
runtime's file routes and fills the whole page, so it only means something inside a served app. The
knowledge example
is a full workspace bound to a data/ tree with an editable CSV and the three virtual folders above.
board.kanban
What it is. A full-bleed kanban board that groups rows of a dataset into columns by a status or
stage field. When to use it: work queues, incident triage, or any pipeline where cards move
between columns. Pick it over table.grid /
status.grid when the main interaction is moving a row
between stages, and over form.entry when you're reassigning an existing row rather
than inserting one.
Cards show titleField plus optional details fields. Declared columns always render (even when
empty); values present in the data but missing from columns still appear so nothing is hidden.
Moving a card — HTML5 drag-and-drop or the per-card column select — POSTs match + set through
the bound update action (match on key, set column), then the dataset refreshes
live. Without action, or with readOnly: true, the board is view-only. Renders full-bleed like
content.editor (no ordinary island card chrome).
{
"type": "board.kanban",
"title": "Incidents",
"dataset": "incidents",
"key": "incident_id",
"column": "status",
"titleField": "title",
"columns": ["investigating", "monitoring", "resolved"],
"details": ["severity", "service"],
"action": "move_incident",
"span": 12
}Key options.
dataset,key,column,titleField— required.keyis the row identity used in the update match;columnis the field that places a card;titleFieldis the card headline.columns(optional): ordered column values to always show, including empty ones.details(optional): extra dataset columns shown under each card title.action(optional): a manifest action withmode: "update"on the same dataset. Omit (or setreadOnly) to disable moves.readOnly(optional, defaultfalse): force a viewer.
Note
There's no live preview for board.kanban here: moves write through the action path. See the
operations example's Incidents board for a working setup.
form.entry
What it is. A data-entry form card bound to a manifest action. When to use
it: when a human, not just an agent, should add rows — log an incident, record a meal, file an
expense — right next to the data it feeds. It's the human-facing mirror of the agent's runActions:
you don't re-declare fields, you point it at an action and it derives one typed input per field from
that action's resolved row schema. So it binds no dataset of its own — the action already names
the target.
The card renders a text, number, or date input per field, a dropdown for an enum field, and a
checkbox for a boolean, with a submit button in the bottom-right. A field is required unless the
action gives it a default. On submit it inserts a row through the same path as the MCP
oi.app().runActions — the row is validated, snapshotted to history for rollback, then inserted —
and the bound dataset's islands refresh live.
{
"type": "form.entry",
"title": "Report incident",
"action": "log_incident",
"submitLabel": "Report"
}Key options.
action: the only required field; the name of a manifestactionsentry this form writes to. Its inputs — types,enumdropdowns,min/max,defaults, and field descriptions — all come from that action's resolved row schema, so the form stays in step with the data.fields(optional): the action's columns to render, in this order. Omit it to render every insertable column; each name must be a real column of the action's dataset, checked at compile.submitLabel(optional, default"Add"): the text on the submit button.
Note
There's no live preview for form.entry here: submitting writes a real row through the action's
checkpointed insert path, so it only means something inside a served app bound to an action. See
Actions for the write path it shares with the agent.
layout.row
What it is. A structural full-width row that holds other islands. When to use it: when
you want a group of islands to sit on their own grid row rather than flowing into the page's
column packing. It carries no span, no title, no dataset, and can't be nested; it exists
purely to arrange its children.
Its children render on their own fresh 12-column grid row, each sized by its own span. The row
itself is transparent to island indexing: a validation error names the child island, not the
row.
{
"type": "layout.row",
"islands": [
{
"type": "metric.kpi",
"title": "Net worth",
"dataset": "net_worth",
"value": "net_worth_eur",
"format": "eur",
"span": 6
},
{
"type": "metric.kpi",
"title": "Cash",
"dataset": "net_worth",
"value": "cash_eur",
"format": "eur",
"span": 6
}
]
}Key points.
islands: the only field; one or more child islands, each a normal built-in island with its ownspan.- No
span,title, or data binding on the row itself. - No nesting: a
layout.rowcan't contain anotherlayout.row.
Tip
There's no live preview for layout.row because it's a layout primitive, not a visual; it only
means something in the context of a full page grid. Reach for it when two or three tiles should
always share a row regardless of what comes before them.
Tabs (groups)
What it is. A page-level split into tabs. A page holds either a flat islands array
or a groups array — never both. Each group is { id, title?, islands } and renders as a
tab under the page header, deep-linked via ?group=<id>. When to use it: when one page
carries more than fits a single scroll — split related islands into tabs instead of spilling onto
a second sidebar page.
{
"id": "labs",
"groups": [
{ "id": "latest", "title": "Latest", "islands": [ /* ... */ ] },
{ "id": "panels", "title": "Panels", "islands": [ /* ... */ ] },
{ "id": "history", "title": "History", "islands": [ /* ... */ ] }
]
}Key points.
- Each group is its own fresh 12-column grid, sized exactly like a flat page.
groupsis not an island — it's page structure, the same tier as the page itself. It lives on the page, not in an island list, so it can't sit inside alayout.rowor another group, and it won't appear in the island catalog oroi.app().listIslands().
See Pages and groups for the page-level shape and the
islands/groups rule.
Tables & Feeds
Three islands for rows you read one at a time — a table when exact values matter, a feed for things that happened in order, and a search box to find one row in many.
Custom Islands
When no built-in island fits, register your own — a React renderer that lives in your project, gets a typed config, and is checked by the same validator.