Plugin Pages
A [[tool.knot.pages]] entry serves a live page under /plugins/<name><path>. The page handler is pure layout logic: it returns rows of columns, each column declaring its type, its own data handler and refresh. The client renders the shell instantly (with loaders), then fetches each column’s data independently - a slow panel never delays the page, a failing panel is an error card in its column, and each panel refreshes on its own timer.
# [[tool.knot.pages]]
# path = "/home"
# handler = "dashboard"
# ...
def dashboard(request):
return {"rows": [
{"title": "Fleet status", "columns": [
{"id": "kpi", "type": "stats", "handler": "kpi", "refresh": 30},
]},
{"columns": [
{"id": "cpu", "type": "chart", "title": "CPU", "handler": "cpu_chart", "refresh": 10, "width": 3},
{"id": "spaces", "type": "table", "title": "Spaces", "handler": "spaces_table", "width": 1},
]},
]}Any other return value renders as a read-only key-value view.
Rows and columns
The document is always {"rows": [...]}. A row has an optional title, an optional style: "card" (the whole row is one card and the columns are unstyled panels; the default styles each column as its own card), an optional permission gate, and a columns list. A column has:
id- the data-binding key (auto-generated asr<row>c<col>when omitted); identifies the column for refresh targeting, so keep ids unique within a page. Data is fetched from the column’s handler URL - the page path plus/<handler>(e.g./plugins/my-plugin/dashboard/spaces).type- what renders the column:stat,chart,table,form,markdown,html,text,bar. Markdown covers code (fenced blocks) and lists (plain bullets);textis the literal type - escaped, whitespace preserved, no markdown semantics, right for timestamps and captions.title- the column heading.handler- the function that supplies this column’s data. Self-contained: each call runs as the requesting user, calledhandler(request)with a freshrequestargument and a clean module state — environments are pooled per plugin and bound to the requesting user per call, so nothing persists between requests. Compute what you need per call.refresh- seconds (5-3600); the client re-fetches just this column.width- 1 to 4 (default 4); the row is always full width, divided into N columns on wide screens and stacking on narrow ones.permission- a gate enforced by knot; a row left with no columns is never sent.
Column handlers
Each handler returns JSON for its type:
stat- a single KPI card{label, value, unit?, delta?, accent?}; a KPI row is a row of stat columns.chart-{chart_type: line|bar|doughnut|pie, labels, datasets: [{name, data, color?}], height?}; drawn by knot’s bundled chart.js.table-{columns: [{key, label, badge?}], rows: [...]}plus optionalactions(below).form- see below.markdown-{markdown: "..."}; GFM rendered server-side into knot’s prose (trusted like html: plugins are admin-installed).html-{html: "..."}; trusted inline markup - see Raw HTML for thekp-*helpers, theming, and the rule against Tailwind classes.
Forms: one handler, two faces
A form column’s handler branches on request["method"]: GET returns the definition, POST receives the submitted fields in request["params"] and returns an envelope - ok with a message, column refreshes and an optional dialog, or error with per-field errors painted back onto the form. After a successful submit a plain form resets to its initial values; an auto_submit filter form instead keeps its values and folds them into the page’s parameters. The full contract - every field type (text, number, the Ace-edited textarea, select, autocomplete with dynamic options, hidden), the envelope keys, and form popups - is on Plugin Forms.
Table actions
A table column may declare per-row actions. A row may also carry its own actions list, which replaces the column’s set for that row - the handler decides, per row, what is offered (a stopped row offers Start, a running row Stop):
{"id": "spaces", "type": "table", "handler": "spaces_table", "width": 3, "actions": [
{"label": "Restart", "action": "restart", "icon": "assets/restart.svg", "confirm": "Restart this space?"},
{"label": "Edit", "action": "edit", "icon": "assets/edit.svg", "handler": "space_edit"},
]}An action has:
action- the name POSTed withkey(the row’sidorname) to the column’s own handler.label- the button text; for icon buttons also the tooltip and screen-reader label.icon- a relative path to one of the plugin’s declared icon assets (icons = ["assets/view.svg"]in[tool.knot]): loaded and sanitized at load like every plugin asset, inlined themed with the UI, and shared by inline buttons and kebab items. With an icon the button renders icon-only, like the spaces list rows; anything the plugin did not declare renders a text button. Plugins bring their own icons - knot ships no built-in action set.style-success,warning,dangeror default blue colour semantics.menu: true- collect into the row’s kebab dropdown instead of an inline button. A row can have any mix: any number of inline buttons (icon or text) and any number of menu items; the kebab only appears when there is something to put in it.confirm- ask first in a modal; the action’sstylepicks the treatment. Adangeraction gets knot’s delete look - Confirm Delete title, trash header icon, a Keep button, and a trash-icon’d confirm button carrying the action label (the group delete’sDelete Groupwith your label instead); any other style gets the neutral confirm (Cancel / action label).handler- clicking GETs this function withkeyand opens a popup (below).
Actions without handler POST {action, key} to the column’s handler URL and handle the envelope like a form POST.
Popup actions
An action naming a handler opens a popup: the client GETs that handler’s URL with the row key (/<handler>?key=<row key>), and the response shape decides what the popup is. The fetch-time gate serves an undeclared handler only if the layout names it - so a popup action is also declared on its column in the page layout ("actions": [{...}] on the column); row actions from the column’s data replace that set at render time, and a row with no actions of its own falls back to the column’s. (A [[tool.knot.handlers]] declaration stands on its own gate instead and needs no layout entry.)
A form popup returns {title?, fields, submit?, cancel?} - the same field contract as form columns. Submit POSTs to the same handler (with key): error keeps the popup open with field_errors painted on the inputs, ok closes it, notifies and refreshes.
An information popup returns {title?, markdown} (or html) - read-only, rendered server-side like a markdown column, with a Close button.
Popups are knot dialogs: draggable, resizable, focus-trapped, closable with Escape and restored focus on close.
Success dialogs
Any ok envelope, from a form or an action, may also carry a dialog - {title, markdown} - which opens as an information popup after the toast. The markdown is rendered server-side, like every markdown payload:
return {"status": "ok", "message": "Report generated.",
"dialog": {"title": "Report", "markdown": "**Done.**\n\n- one thing\n- another"}}The html column
Trusted markup, rendered raw - the full helper-class reference (kp-*), theme rules, available globals (Alpine, Chart.js) and refresh semantics are on the Raw HTML page.
Accessibility
Presentation lives in knot’s renderer, so pages inherit it: semantic headings/tables/labels, state never by colour alone, role="status" loaders per column, charts labelled with a text summary, notifications through a live region, and refreshes defer while the user is reading (focus/pointer in the region). The trusted html column is excluded from the guarantee.
The dispatch model
When a user opens the page, knot checks the page gate (declared permission - knot enforces, plugins can’t forget it), evaluates the entry file, calls the handler as the requesting user - handler(request), addressed plugin.<namespace>.<fn> (see the request argument) - enforces the row/column gates, and serves the layout.
Every handler is also addressable as a URL, and a handler fetch runs that handler directly - auth and the gate checked, then the call:
/plugins/<name>/<page-path>/<handler>- runs through that page’s gate (and, below, the column gates) unless the handler has its own declaration;/plugins/<name>/<handler>- plugin root; serves only handlers with a[[tool.knot.handlers]]declaration, whose gate applies.
Handlers are ajax endpoints: the plugin’s own pages, another plugin’s pages (see pluginFetch), or a user with curl all fetch the same URLs, always answered as JSON. A handler may declare its own gate:
# [[tool.knot.handlers]]
# handler = "export_all"
# permission = "admin" # optional; must be declared in [tool.knot] permissionsA declared gate is authoritative everywhere the handler is called - page path, plugin root, or a column fetch - the same semantics as row/column gates. Declaring a handler also opts it into plugin-root addressability (what cross-plugin pluginFetch uses).
Undeclared handlers are reachable only through a page, and the column gates hold at fetch time, not just when the layout is pruned: knot runs the page’s layout handler as the requesting user (row/column gates applied) and serves the handler only if that layout offers it - as a column’s handler or an action’s popup handler. That answer is memoized for a few seconds per user and query, so a page’s columns fetching as a burst cost one layout run, not one per column; a handler the layout withdraws (or a gate revoked via a role edit) stops being fetchable within that window. A user who fails a column’s permission cannot fetch that column’s data by naming its handler directly, and a handler no layout references must carry a [[tool.knot.handlers]] declaration to be callable at all. That includes popup handlers named by data-driven row actions: a table payload’s rows can carry their own action lists, but the layout gate only vouches for what the layout itself names — so a handler a payload row names is refused unless it is declared (give it the page’s own permission to keep the gate equivalent).
So the full model, outermost in: the page gate decides the page and every handler riding its path; row gates are presentation (gated rows vanish with their columns, hiding their handlers); column gates decide both the column’s visibility and its handler’s fetchability; a handler declaration replaces the inherited gates wherever it applies. Handler environments: the scriptling standard library, data formats, templating, text processing (jailed to the plugin folder), scriptling.ai, the knot.* libraries as the requesting user, and binary peers as plugin.<name> imports. Installed plugins share one plugin pool and one trust domain, so other plugins’ exposed surfaces are importable as plugin.<name> too (composition). No outbound networking, no container/nomad, no filesystem outside the plugin folder.
Examples
The dashboard example is a real landing page on this contract - live knot.* aggregates and history, a spaces table with state-dependent row actions and an edit popup, and it claims the post-login default with default = true; demo-scriptling’s showcase exercises every column type, the full action set (icon buttons, kebab menu, both confirm styles, popup forms, markdown popups, success dialogs) and a one-handler form - every action icon a plugin-declared asset; demo-go measures real peer latencies into charts.