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 as r<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); text is 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, called handler(request) with a fresh request argument 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 optional actions (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 the kp-* 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 with key (the row’s id or name) 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, danger or 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’s style picks the treatment. A danger action 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’s Delete Group with your label instead); any other style gets the neutral confirm (Cancel / action label).
  • handler - clicking GETs this function with key and opens a popup (below).

Actions without handler POST {action, key} to the column’s handler URL and handle the envelope like a form POST.

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] permissions

A 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.