Skip to content

Views

A view renders a live table or board over a set of pages without any custom code: you describe a data source and a shape, and Subspace’s shipped, code-reviewed view components do the rendering. Because a view is described by data, not by a component you write, a plugin can ship a view with zero JavaScript, just a page.

A view-spec element lives in a page like any other element, and its configuration is its page content, so it rides OKF export/import and multiplayer editing the same as everything else. Its shape:

type ViewSpec = {
type: 'view-spec';
source: { dir: string };
view: 'table' | 'board';
fields?: string[]; // metadata dot-paths, e.g. 'crm.company'
groupBy?: string; // board lanes; misses land in '(none)'
filter?: { path: string; eq: unknown }[];
};

Data comes from kb.dir.pages { slug }, every page under the given directory that carries metadata. Because it’s a live query rather than a snapshot, any command that touches a page in that directory refreshes the view over the pages topic, no manual reload.

  • table renders a title column plus one column per entry in fields.
  • board renders one lane per distinct value of groupBy (pages missing that field land in a trailing (none) lane); cards show the page title plus whichever fields are non-empty on that page.

filter narrows the source to pages where a metadata path equals a given value, so a view can, for example, show only people with crm.kind equal to "customer" out of a directory that also holds companies and deals.

A query element is the more capable sibling of view-spec: five views instead of two, richer sources, and comparison predicates rather than a single equality filter.

{
type: 'query';
source: { dir: 'projects' }; // exactly one source, see below
where: [{ field: 'status', op: 'neq', value: 'archived' }];
sort: [{ field: 'due', dir: 'asc' }]; // up to 5
group: 'status'; // lanes for board, sections for list
fields: ['status', 'due']; // up to 50
view: 'table' | 'list' | 'board' | 'card' | 'calendar';
limit: 100; // default 100, capped at 500
}

Sources — exactly one of { dir } (returns pages), { type }, { tag }, { checkbox: { checked } } (all return nodes), or { search: { q, kinds } }. On a search source, kinds narrows the result to the indexed corpora you name, any of node, memory, file, and mail. Omit it to search everything. Naming any kind also drops page-title matches, which have no corpus of their own and so cannot be asked for.

Predicateseq, neq, lt, lte, gt, gte, contains, exists. Comparisons are type-gated: a numeric predicate only matches rows whose stored value is a number, so a number kept as a string will not match gt: 5. Date values accept the macros {{today}}, {{today+3d}}, and {{today-2w}}, which is how a saved query stays live against the current date.

Fields you can filter, sort, or group on are page metadata (dotted paths like crm.stage work), key:: labels, typed node properties, and the built-ins id, pageId, title, slug, kind, tags — plus nodeId and text on node sources. Built-ins win over same-named metadata. Note that created_at and updated_at are not available.

Results are ACL-filtered before the limit is applied, so a page you cannot read never consumes a result slot. The element re-runs on every knowledge-base write, and a board card dragged to another lane writes the new group value back through the ordinary audited command path.

Creating and editing: type /query on any bullet to insert a starter query (open checkboxes as a table), then use the frame’s edit action to change the source, view, fields, grouping, sort, filters, and limit as a form — applied as one ordinary setElement command, so it versions and multiplays like any other edit. The element is still plain OKF (a ```subspace:query fenced block), so agents and the SDK write the same JSON the form does. The built-in Todos page at /p/todos is a shipped { checkbox: { checked: false } } list query. When a result set fills the limit (or the server’s 500 cap, 50 for search), the element shows showing first N — more may match so a truncated set is never mistaken for a complete one.

Two sharp edges worth knowing. Calendar uses the declared dateField when set (the edit form exposes it for the calendar view); without one it falls back to the first entry in fields that any row can parse as a date, and with neither it renders calendar needs a date field in fields. And a search source is answered by hybrid search rather than the query compiler, so its rows carry only kind, pageId, title, slug, rank, and text — filtering one on page metadata matches nothing — its limit is capped at 50 regardless of limit, where and sort are applied in the browser, and the memory kind comes back as mem.

Two places in Subspace expose a purpose-built Kanban board on top of the same drag-to-move interaction, rather than a generic view-spec:

Project and directory pages. Every directory page (other than the workspace root) shows list | board tabs next to its title. Board mode lanes the directory’s child pages by a status field, metadata.status by default, configurable per directory via { board: { statusField: 'stage' | 'crm.stage' | …, columns: [...] } } on the directory page’s own metadata. Columns default to todo / doing / done; any status value seen in the data that isn’t in the configured column list gets its own extra lane, and pages with no status land in a trailing (none) lane. Cards show the page title and its last-updated time; clicking one opens the page. Dragging a card to a different lane issues an ordinary setPageMetadata command, the same audited write path as any other metadata edit, and dropping on (none) clears the field.

The task queue. The task queue has its own list | board tabs, where the board’s columns are its four sections, Needs confirmation, Up next, Scheduled, Done, rather than an arbitrary status field. Cards show the task’s title and metadata (kind, due date, run status, or completion time). Only the Done column accepts a drop: dropping a card there is the one server-side resolution action, routed through the same audited ops.tasks.resolve path the Confirm button or a completed checkbox use (a needs-confirmation card resolves as confirm, an up-next item resolves as done). Scheduled and Done cards are informational and can’t be dragged.

When a table or board isn’t expressive enough, a plugin can ship a full ESM viewer instead: a compiled JavaScript bundle registered against a viewer id, loaded through an import map at runtime, and rendered wherever a page references it. This is how richer, domain-specific surfaces (a metric chart, a sweep board) get built without forking core view rendering. See views and surfaces for how a plugin registers one.