The canopyx/2 data protocol
The JSON documents a host sends to the CanopyX runtime, each beside the grid it produces, from the view definition to children pages and hosted endpoints.
The C# types write these documents and the browser runtime reads them. A host that is not .NET can write the same JSON by hand. Every top-level document carries "protocol": "canopyx/2" and a type; readers ignore fields they do not know, so a newer writer's document still loads. The examples below show the main document shapes beside the grids they produce.
A view and the tree it produces
CanopyTreeView.ToJson() writes a view: columns, behaviour, the data source and, for an inline source, the nodes. A cell is a bare value or an object with a value for sorting, a text to show and a tone the theme turns into a colour. Every string is inserted as text, never as HTML.
{
"protocol": "canopyx/2",
"type": "view",
"id": "files",
"label": "Project files",
"rowHeight": 28,
"pageSize": 100,
"selection": "single",
"columns": [
{"id": "name", "title": "Name", "width": 260, "kind": "text"},
{"id": "size", "title": "Size", "width": 100, "kind": "number"}
],
"search": {"minLength": 2, "maxResults": 20},
"source": {"kind": "inline"},
"roots": [
{
"id": "projects",
"label": "Projects",
"hasChildren": true,
"childCount": 2,
"expanded": true,
"children": [
{
"id": "plan",
"label": "Project plan.docx",
"cells": {"size": {"value": 48128, "text": "47 KiB"}}
},
{
"id": "report",
"label": "Quarterly report.pdf",
"cells": {"size": {"value": 2048, "text": "2 KiB", "tone": "warning"}},
"badges": [{"text": "new", "tone": "info"}]
}
]
}
]
}A grid and the table it produces
Flat grids have their own grid document, without parents or a tree column. CanopyDataGridView.ToJson() writes it, and a host serves later windows of the same query as gridPage documents.
{
"protocol": "canopyx/2",
"type": "grid",
"id": "sites",
"label": "Sites",
"rowHeight": 34,
"noun": "sites",
"pageSize": 100,
"selection": "single",
"search": true,
"columns": [
{
"id": "site",
"title": "Site",
"width": 160,
"autoWidth": true,
"kind": "text",
"sortable": true
},
{
"id": "devices",
"title": "Devices",
"width": 160,
"autoWidth": true,
"kind": "number",
"sortable": true
}
],
"source": {"kind": "inline", "navigation": "offset"},
"rows": [
{"id": "warsaw", "cells": {"site": "Warsaw", "devices": 412}},
{"id": "london", "cells": {"site": "London", "devices": 318}},
{"id": "berlin", "cells": {"site": "Berlin", "devices": 270}}
]
}A page of children
When a lazy node opens, the data source answers with a page of its children. offset says where the page starts, total how many children there are, and next is an opaque continuation the runtime passes back for the next page; null ends the list.
using CanopyX;
var page = new CanopyPage("projects") { Offset = 0, Total = 3 };
page.Items.Add(new CanopyNode("plans", "Plans") { HasChildren = true, ChildCount = 12 });
page.Items.Add(new CanopyNode("budget", "Budget 2026.xlsx"));
page.Items.Add(new CanopyNode("report", "Quarterly report.pdf"));
string json = page.ToJson();{
"protocol": "canopyx/2",
"type": "page",
"parent": "projects",
"offset": 0,
"total": 3,
"items": [
{"id": "plans", "label": "Plans", "hasChildren": true, "childCount": 12},
{"id": "budget", "label": "Budget 2026.xlsx"},
{"id": "report", "label": "Quarterly report.pdf"}
],
"next": null
}Hosted endpoints
A remote source names URL templates. The runtime fills the placeholders with URL-encoded values and sends GET requests; your application answers children with a page, search with a search document of id paths, and counts with a counts document.
"source": {
"kind": "remote",
"children": "api/files/children?ref={ref}&cursor={cursor}",
"search": "api/files/search?q={query}&limit={limit}",
"counts": "api/files/counts?ids={ids}"
}A response with a failing status, invalid JSON, or the wrong protocol or type shows an error row with a retry action in place of the children. Authorization, page-size limits and query-time limits belong to the host's endpoints.
Documents
| Type | Written by | Read when |
|---|---|---|
| view | CanopyTreeView.ToJson(), or CanopyTreeView.WriteJson(stream) for the same bytes without the whole string | The page loads; it defines columns, behavior, the data source, and inline nodes |
| grid | CanopyDataGridView.ToJson() | A flat-grid definition supplies columns and inline rows or a host source |
| gridPage | CanopyGridPage.ToJson() | A host supplies a revisioned page of ordered matching rows |
| gridQuery | CanopyGridQuery.ToJson(view) | A host receives a typed query and requested result window |
| gridRangeQuery | The browser; CanopyGridRangeQuery.ToJson(view) | A calendar or timeline asks a host for the records that overlap a period (Date ranges) |
| gridRangePage | CanopyGridRangePage.ToJson(view) | A host answers a range request with the overlapping records in start order |
| gridCommit | The editing part | A grid asks its writer to save changed rows (Editing and commit) |
| gridCommitResult | The host's writer | The writer answers per row: saved, error or conflict |
| gridRecordQuery | The browser; CanopyGridRecordQuery.ToJson() | A record's drawer opens and its details load on demand (Record details) |
| gridRecord | CanopyGridRecord.ToJson(view) | A host answers a record request |
| gridRecordPack | CanopyRecordSidecar.Write | A record's drawer opens and its details are in a record pack beside the page |
| gridTransaction | CanopyGridTransaction.ToJson(view) | A host sends rows to add, update or remove to an open grid (Live data) |
| page | CanopyPage.ToJson(), bundle writer | A node opens, or the user asks for more children |
| search | CanopySearchResult.ToJson() | A remote search endpoint answers |
| counts | CanopyCounts.ToJson() | A live host refreshes counts and rollups without reloading rows |
| manifest | Bundle writer | A sidecar view loads; it points at the first top-level page and describes the packs and the search index |
| pack | Bundle writer | A node opens whose children page is stored in a pack file next to the pages of other nodes |
| searchShard | Bundle writer | A sidecar search needs the words that start with a prefix |
| searchEntries | Bundle writer | A sidecar search needs the nodes its matches refer to |