Skip to content

Runtime Protocol

Status: draft, implemented by zeyn::server and assets/zeyn/*.js (served as /_zeyn/zeyn.js).

This is the contract between the Zeyn server and the browser runtime. Screens are rendered on the server. The browser runtime is a small script that sends user actions to the server and applies what comes back. Application code never writes JavaScript.

1. Page load

GET {route}?params={json} returns a full HTML document. params is the URL encoded JSON of the screen params and is omitted when the screen has none.

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta name="zeyn-csrf" content="TOKEN">
  <title>Products · Inventory</title>
  <link rel="stylesheet" href="/_zeyn/zeyn.css">
  <script type="module" src="/_zeyn/zeyn.js"></script>
</head>
<body>
  <a class="z-skip" href="#z-main">Skip to content</a>
  <div class="z-app">
    <nav class="z-nav" aria-label="Main">...</nav>
    <main class="z-main" id="z-main">SCREEN</main>
  </div>
  <div class="z-toasts" role="status" aria-live="polite"></div>
</body>
</html>

The menu lists only the screens the current user may open. The current item has aria-current="page".

2. Screen markup

Every screen root carries its name and params:

<section class="z-screen" data-zeyn-screen="product_list" data-zeyn-params="null" aria-labelledby="product_list-title">

Windows use class="z-window" role="dialog" aria-modal="true" and a --z-width style.

Each body item is wrapped in a node element. Its path is the address used by the endpoints below. Containers (Row, Stack, Panel, Card, Tabs, Split) wrap each child the same way, and the child's path adds its index: the third child of the first child of body item 1 is "1.0.2".

<div class="z-node" data-zeyn-node="1">
  <div class="z-node" data-zeyn-node="1.0">...</div>
</div>

Endpoints take node as a dotted string or, for body items, as a plain number (0). Paths have at most 16 levels of digits; anything else is refused with 422.

3. Attributes the runtime reads

AttributeOnMeaning
data-zeyn-effectbutton, trJSON effect to run on click (rows: on double click or Enter)
aria-keyshortcutsbuttonKeyboard shortcut, for example Control+N
data-zeyn-exportbuttonExport format of the grid on the same screen (csv, xlsx)
data-zeyn-gridgrid rootMarks a grid; also has data-zeyn-mode, data-zeyn-paging, data-zeyn-page-size, data-zeyn-total, data-zeyn-next, data-zeyn-offset
data-field, aria-sort, aria-colindexthColumn field name, current sort, position
data-filter, data-optionsthFilter input kind (text, number, money, date, datetime, time, choice, bool) and choice options [{value,label}]
data-pinthstart or end for pinned columns (also the selection and row action columns)
data-edit, data-optionalthInput kind of an editable column, for users who may save
data-zeyn-texts, data-zeyn-columns, data-zeyn-viewgrid rootLocalized control texts, every column [{field,label,hidden,pin,width}], the saved view
data-zeyn-viewsgrid rootserver when views are saved on the server
data-zeyn-version-field, data-zeyn-exportablegrid rootRow version field name for inline edits; csv when the grid can be exported
data-zeyn-key, data-zeyn-version, data-zeyn-menutrRow key, row version, context menu items
data-vtdInput value of an editable cell
data-zeyn-detail, aria-expandedtrDetail screen of the row as {screen, params}; whether it is open
data-zeyn-expandbutton in a rowOpens or closes the row's detail, group or children
data-zeyn-level, aria-level, data-zeyn-pathtr in a treegridDepth (0 at the top) in a grouped or tree grid; the path of group values or ancestor keys to send when the row opens
z-grouptrA group header row; z-branch-more is a "Load more" row of a branch
data-zeyn-runbutton in a rowRow item to run (a0 is the first row action)
data-aggtfoot tdTotals the column shows (sum avg min max count)
data-zeyn-bulkdiv before the gridSelection bar with data-zeyn-bulk-run buttons
data-zeyn-menuscreen rootContext menu of the screen
data-zeyn-native-menu="off"htmlThe browser's context menu is off except in text inputs
data-zeyn-rowstbodyWhere rows go
data-zeyn-pagepager buttonprev or next
data-zeyn-page-infospanText such as 1-50 of 230, built from the localized templates on the grid root
data-zeyn-text-range, data-zeyn-text-range-total, data-zeyn-text-loadedgrid rootLocalized templates: {from}-{to}, {from}-{to} of {total}, {count} rows loaded
data-zeyn-moredivInfinite scroll sentinel, contains the "Load more" button
data-zeyn-submitformName of the submit action
data-zeyn-editorinputClient side editor of a custom field type

Effects:

EffectJSONRuntime does
Open window{"effect":"open_window","screen":"product_form","params":{...}}Loads the screen (section 4.1) into a modal <dialog>
Navigate{"effect":"navigate","screen":"...","params":{...}}Loads /_zeyn/route/{screen}?params= which redirects to the page
Toast{"effect":"toast","message":"Saved"}Shows a toast for 4 seconds
Close and refresh{"effect":"close_and_refresh"}Closes the top dialog and reloads every grid on the screen under it

4. Endpoints

All POST requests send JSON, must carry the header x-zeyn-csrf with the token from the zeyn-csrf meta tag, and are rejected with 403 otherwise.

4.1 Screen fragment

GET /_zeyn/screen/{screen}?params={json} returns the screen HTML only, without the page shell. Used for windows and for row details: opening a row with data-zeyn-detail loads its screen into a new tr.z-detail right under it (one cell spanning the row, holding a role="region"), and closing removes that row. The screen's permission is checked like any screen; grids inside the detail talk to the server with the detail screen's own name and params.

4.2 Grid rows

POST /_zeyn/grid

{
  "screen": "product_list",
  "params": null,
  "node": 0,
  "query": {
    "page": { "type": "offset", "offset": 0, "limit": 50 },
    "sort": [{ "field": "code", "desc": false }],
    "filters": [{ "field": "name", "contains": "tea" }, { "field": "price", "from": "10", "to": "99.90" }],
    "with_total": true,
    "aggregates": [{ "field": "price", "op": "sum" }]
  },
  "view": { "columns": [{ "field": "code", "width": 140, "pin": "start" }, { "field": "name" }] },
  "head": false
}

Filters may set contains, starts_with, equals, from, to, any_of and is (see docs/grid.md). view.columns is the column layout to render the rows in; with head: true the response also has head, the header row.

For infinite scroll page is { "type": "after", "cursor": "...", "limit": 100 } and cursor is null for the first batch.

Response 200:

{ "rows": "<tr>...</tr>", "foot": "<tr>...</tr>", "head": null, "count": 50, "total": 230, "next": "50" }

foot is the totals row when totals were asked for, otherwise null.

total and next may be null. The runtime replaces the rows for paged grids and appends them for infinite scroll.

Grouped and tree grids (<table role="treegrid">) send path with POST /_zeyn/grid to load a branch: the group values (or ancestor keys) of the row being opened, as found in its data-zeyn-path. The response has the same shape; rows holds group rows or rows one level deeper, and total counts them. The runtime puts them after the opened row and removes every deeper row that follows it when the row closes. A path longer than the grid's group levels (or 32 in a tree) is 404.

4.2.1 More grid endpoints

EndpointBodyDoes
POST /_zeyn/grid/view{screen, params, node, view}Saves the user's view (null deletes it). 204; 404 without a view store
POST /_zeyn/grid/run{screen, params, node, key, item}Runs a row item (a0 row action, m2.1 menu path). The server loads the row by key and builds the input. {effects}
POST /_zeyn/grid/bulk{screen, params, node, action, keys, all, query}Runs a bulk action with the picked keys, or with every row matching query.filters when all. {effects, count}
POST /_zeyn/grid/edit{screen, params, node, key, values, view}Saves an inline edit; values hold the edited columns and the row version as a form sends them. {row}; 403 for columns that are not editable, 409, 422
GET /_zeyn/grid/export?screen&params&node&q={sort,filters,columns}Streams every matching row as CSV
POST /_zeyn/menu{screen, params, item}Runs an action item of the screen's context menu

4.3 Form submit

POST /_zeyn/form

{ "screen": "product_form", "params": { "id": 7 }, "node": 0, "values": { "code": "P-001", "price": "1.234,56", "active": true } }

Values are sent as the user typed them: strings for inputs and selects, booleans for checkboxes. The server parses them by field type.

The server starts from the model the screen loaded and overwrites only the fields that are on the form and not read only. Other values from the client are ignored. Keys never come from the client.

Responses:

  • 200 { "effects": [ ... ] }: saved; run the effects in order.
  • 422 { "errors": [ { "field": "code", "rule": { "rule": "required" }, "message": "Code is required." } ] }: show each message under its field (#{scope}-{field}-error), set aria-invalid="true" on the input and focus the first invalid input. Errors with an empty field are shown at the top of the form.

4.4 Errors

Any endpoint may answer with:

StatusBodyMeaning
403{ "error": "forbidden", "message": "..." }Missing permission, guard denied or bad CSRF token
404{ "error": "not_found", "message": "..." }Unknown screen or node
409{ "error": "conflict", "message": "..." }Someone else changed the record
422see 4.3Validation
504{ "error": "timeout", "message": "..." }The call took longer than the server limit
500{ "error": "internal", "message": "An unexpected error occurred." }Details are only in the server log

The runtime shows the message as an error toast.

4.5 Language

POST /_zeyn/locale with { "locale": "tr" } stores the choice in the zeyn_locale cookie and answers 204. The runtime reloads the page. Without a cookie the server picks from Accept-Language, then the app default. <html lang> always matches the chosen locale.

The shell renders a language selector (select[data-zeyn-locale]) when the app has more than one locale.

4.6 Theme

The shell renders a theme selector (select[data-zeyn-theme-picker]) unless the app hides it or offers a single theme. Each option carries data-scheme (light, dark, empty for System) and app themes data-custom. On change the runtime sets data-theme and data-zeyn-theme on <html> and the color-scheme meta at once, then sends POST /_zeyn/theme with { "theme": "dark" }. The server answers 204 with the zeyn_theme cookie, or 404 for a theme the app does not offer. Pages are rendered with the theme from that cookie, so there is no reload and no flash. See docs/theming.md.

4.7 Enterprise endpoints

enterprise.js and enterprise.css load next to the runtime. Details are in docs/enterprise.md.

EndpointPurpose
POST /_zeyn/reveal {screen, params, node, field, key?}Raw value of a masked field, {value}; needs the unmask permission, always audited
POST /_zeyn/reason {screen, reason}Reason for a screen marked reason; 204 with an HttpOnly cookie
POST /_zeyn/import {screen, params, node, csv, commit}CSV preview (commit: false) or import
POST /_zeyn/keepalive"Stay signed in" after the idle warning; 204
GET /_zeyn/health, GET /_zeyn/readyLiveness and readiness; health is not rate limited, readiness only from probe_ips
GET /_zeyn/jobsJob status for users with the jobs permission

Every response carries x-zeyn-build. Refusals the runtime acts on: 428 reason_required (asks for a reason and repeats the request) and 401 reauth_required (asks the user to sign in again and repeats the request).

5. Security

  • Every response sets Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; style-src-attr 'unsafe-inline'; img-src 'self' data:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none', X-Content-Type-Options: nosniff and Referrer-Policy: same-origin.
  • No inline scripts or inline event handlers. Inline style attributes are used only for CSS custom properties (--z-width, --z-flex) and fixed column widths, so the policy adds style-src-attr 'unsafe-inline'. Scripts stay strict.
  • The CSRF token is stored in an HttpOnly, SameSite=Strict cookie and compared in constant time with the x-zeyn-csrf header.
  • Each call checks, in order: authentication, the screen's access, the feature's guards, the node action's access, then runs the interceptors and the action.

4.6 Virtual scrolling

Grids with data-zeyn-paging="virtual" show one scrollable list over every row, however many there are. The server renders the first block (data-zeyn-page-size rows, offset 0) with data-zeyn-total. The grid root also has data-zeyn-row-height (pixels) and the CSS variable --z-row-height.

The runtime:

  • Puts the table body in a scroll container whose inner spacer is total * rowHeight pixels high, capped at 8,000,000 px. Above the cap, scroll position maps to row index by ratio (row = scrollTop / (spacerHeight - viewport) * (total - visibleRows)), so 1,000,000 rows still reach the end.
  • Keeps only the visible rows plus 20 rows above and below in the DOM, positioned with transform: translateY(...). Rows have a fixed height; long text is cut with an ellipsis.
  • Loads blocks of data-zeyn-page-size rows with POST /_zeyn/grid and page: { "type": "offset", "offset": blockStart, "limit": blockSize }, with_total: false after the first call. Keeps up to 20 blocks in a cache (least recently used out), prefetches the next block in the scroll direction, and cancels requests for blocks that left the view (AbortController).
  • Never clears the visible rows while a block loads. Missing rows show a placeholder row with aria-busy="true" until their block arrives, so the screen does not flash.
  • Sort and filter changes drop the cache and scroll to the top.
  • Keyboard: Arrow keys, Page Up, Page Down, Home and End move the active row and scroll it into view. The table has aria-rowcount = total and each row aria-rowindex.
  • The position text (data-zeyn-page-info) uses data-zeyn-text-range-total.

6. Runtime behavior

  • Without JavaScript, pages still render with the first page of every grid. Forms and paging need the runtime.
  • Keyboard: arrow keys move between grid rows, Enter opens a row, Escape closes the top dialog, shortcuts from aria-keyshortcuts work everywhere except inside text inputs. In grids: Space selects the row, F2 edits it, Ctrl+Z undoes the last edit, Shift+F10 or the context menu key opens the menu of the row or header, Shift+click sorts by several columns, Right and Left (mirrored in right to left pages) open and close a row's detail, group or children, and Left on a child row goes to its parent. Right on a row moves into its cells: arrows, Home, End, Ctrl+Home, Ctrl+End, Tab and Shift+Tab move the cell cursor, Shift grows a range (aria-selected on cells), Ctrl+C copies the range as tab separated text and Ctrl+V pastes into editable cells, one POST /_zeyn/grid/edit per changed row. Grids with 40 or more columns keep only the columns in view in the page (a colgroup with fixed widths, td.z-col-gap spacers with colspan, aria-colindex on cells).
  • Right click opens Zeyn's menu where one is defined (headers, rows with data-zeyn-menu, screens with data-zeyn-menu); everywhere else the browser's own menu stays.
  • Focus moves into a dialog when it opens and returns to the opener when it closes.
  • Client side checks (required, minlength, maxlength, min, max, type="email") run before submit with the browser's constraint validation API. The server always validates again.

7. Component library

zeyn::ui components are served by two more assets, linked in the page head after the runtime: /_zeyn/components.js and /_zeyn/components.css. Like the runtime, they need no sign in.

7.1 Endpoints

All are POST with JSON and the CSRF header, and check the screen's access and the feature's guards first.

EndpointBodyAnswer
/_zeyn/node{ screen, params, node }HTML of one node with the first page of its grids. Used by lazy tabs.
/_zeyn/data{ screen, params, node, key, input }JSON from the data source key of the node: lookup options (key is the field name), tree children (key is empty), step checks (key is _step). The source's action permission is checked and the interceptors run, as for grids. Input that does not fit the action is refused with 404.
/_zeyn/parse{ kind, text, label }How the server reads a typed date or time in the user's language, zone and clock: { ok, text, iso, utc, zone } or { ok: false, message }. kind is date, time, datetime, local or presets (today and quick ranges).

7.2 Values the forms submit

  • Lookups submit ids as text: "7", or "3,7,9" for several.
  • Lines submit "{field}": "rows" and one value per cell as "{field}.{line}.{column}", plus "{field}.{line}._i", the index of the loaded line the row came from (empty for new lines). The server rebuilds each line from its loaded line, so only shown, editable columns change.
  • Validation errors of a line use { "field": "lines", "rule": { "rule": "line", "value": { "row": 0, "field": "quantity" } } }.

7.3 Events

EventOnDetail
zeyn:contentdocumentElement with new content (navigation, lazy panels). Both scripts set up what it holds.
zeyn:errorsthe formThe 422 errors, after the runtime showed them. The line editor and stepped forms use it.
zeyn:effectdocumentAn effect the runtime does not know: { effect, source, run }. run(next) runs another effect. Used by confirm.

Effect {"effect":"confirm","message":"...","title":"...","ok":"...","cancel":"...","then":{...}} asks in a modal alert dialog and runs then only after the user confirms.

7.4 Media

Video and Audio are served by /_zeyn/media.js and /_zeyn/media.css, linked after the component library. See docs/media.md.

EndpointDoes
GET /_zeyn/media/{source}/{id}A file of a registered media source with range requests: 200, 206, 304, 416.
GET /_zeyn/media/position?source=&id={ at, kept }, where the signed in user stopped; kept is false when the source keeps no positions (the player then uses local storage).
POST /_zeyn/media/position{ source, id, at } with the CSRF header saves it: 204, 422 for a bad position.

7.5 Islands

With the islands feature, a screen that holds div[data-zeyn-island] is served with <meta name="zeyn-islands"> (island name to { js, wasm } URLs), /_zeyn/islands.js and a policy with 'wasm-unsafe-eval'; other pages are unchanged. A partial answer that holds an island carries x-zeyn-islands: 1, and nav.js loads it in full when the document lacks the island policy.

Attribute or endpointMeaning
data-zeyn-island, data-zeyn-load, data-zeyn-propsIsland name, visible, interaction or idle, props JSON
data-zeyn-island-fallbackFallback content, hidden once the island mounts
data-zeyn-island-stateSet by the loader: loading, ready, failed
GET /_zeyn/islands/{file}A built file listed in the manifest, cached for a year
POST /_zeyn/data with key = remote nameA typed call from the island, with all the checks of 7.1

See docs/islands.md.

7.6 Realtime

With the realtime feature, div[data-zeyn-live="topic"] marks nodes that refresh when the server notifies the topic. Pages holding one link /_zeyn/live.js; a partial answer holding one carries x-zeyn-live: 1 and nav.js imports the script. zeyn:reload on an element reloads the grids inside it in place.

EndpointDoes
GET /_zeyn/live/topics?t=a,b&csrf=WebSocket; text frames { "topic": "a" }
GET /_zeyn/live/doc/{kind}/{doc}?csrf=WebSocket; binary frames 0x01 + Y update (v1) both ways

Handshakes check the origin, the CSRF token against the cookie, the signed in user and the permissions before upgrading. See docs/realtime.md.

The player dispatches the cancelable zeyn:media-adaptive event ({ media, kind, url, hls, dash }) on its root before falling back to progressive files; a streaming library calls preventDefault() to take over.

8. Desktop class shell

/_zeyn/pro.js and /_zeyn/pro.css load after the component library. Like the runtime they need no sign in.

8.1 Commands and the palette

Commands are declared in Rust (Command::open, Command::navigate, Command::effect, Command::action) and registered with Feature::command. The shell renders one hidden button per command the user may run (feature guards pass and the command's access allows it) inside div[data-zeyn-commands]:

AttributeMeaning
data-zeyn-commandCommand id
data-zeyn-effectEffect of an effect command, run by the runtime on click
data-zeyn-command-runId of an action command, run with POST /_zeyn/command
aria-keyshortcutsNormalized shortcut (Control+Alt+N); the runtime's shortcut handling clicks the button
data-hint, data-iconShortcut as shown to users, icon name
data-text-* on the containerLocalized palette texts

App::check refuses shortcuts that cannot be read, single character shortcuts (WCAG 2.1.4), the palette's own shortcut, shortcuts or ids used by two commands, shortcuts the browser never passes to the page (BROWSER_RESERVED: Ctrl+N, Ctrl+T, Ctrl+W, Ctrl+Tab, Alt+F4 and their macOS forms) and, unless the command calls .override_browser(), shortcuts the browser uses (BROWSER_DEFAULTS: print, save, find, address bar, reload, zoom, developer tools, copy and paste, F1 to F12). Toolbar buttons follow the same rules at run time: such a shortcut is not bound and the reason is logged. Free combinations are Alt+Shift+<letter> and Ctrl+Alt+<letter>.

Alt+Shift+P (Option+Shift+P on macOS) opens the palette by default; App::palette_shortcut("Ctrl+Shift+P") changes it, also to a key the browser uses, on purpose. The shortcut is in data-palette-keys, and letters match by physical key so Option and AltGr do not break it. The palette also opens also from button[data-zeyn-palette-open] in the menu. It is a modal <dialog> with an ARIA combobox and listbox. It lists the app's commands, the toolbar buttons, menu bar commands and context menu items of the current screen (the top window when one is open) and the screens in the menu. Matching is fuzzy and ignores case and accents; the eight most recently run commands come first (localStorage key zeyn.palette.recent). Escape closes it and focus returns to where it was.

EndpointBodyAnswer
POST /_zeyn/command{ command }Runs an action command with the input fixed on the server: CSRF, feature guards, the action's permission and the interceptors. { effects }; 404 for unknown ids and effect commands
POST /_zeyn/search{ query }Runs every Feature::search the user may run (CSRF, feature guards, the action's permission, interceptors) with the query cut at 100 characters. { hits: [{ label, group, url }] }, at most 20, only app paths

When the container has data-search, the palette sends the query 150 ms after the last key (two characters or more) and lists the hits under the commands. Only the answer to the latest query is drawn. Choosing a hit sends zeyn:navigate with the base path in front of url; a #anchor in it scrolls to that element after the screen is swapped.

The runtime listens to zeyn:run on document so other scripts run work through it: { effects, source } runs effects, { screen, item } runs a screen context menu item, { error } shows an error toast.

8.2 Menu bar, toolbox, property inspector, status bar

MarkupBehavior
ul[role=menubar][data-zeyn-menubar]W3C APG menubar. Top items are button[role=menuitem][aria-haspopup=menu] with a roving tab stop; each controls a ul[role=menu] that is hidden while closed. Items carry the command attributes of 8.1. Left and Right move between menus, Down, Enter and Space open, Up opens at the last item, Home and End jump, a letter moves to the next item starting with it, Escape closes and returns to the bar, Tab and a click outside close. Menus and items the user may not run are not rendered.
div[role=toolbar][aria-orientation=vertical][data-zeyn-toolbox]Toggle group of button.z-tool[aria-pressed]. Arrows, Home and End move the roving tab stop; a click, Enter or Space turns one tool on, sends zeyn:tool (detail.tool) and writes data-text-selected into the status bar.
section.z-inspectorA form with Form::section groups (details.z-form-section with role=group bodies) saved through POST /_zeyn/form at the inspector's node path. Sections holding failing fields open before the browser's checks and after a 422.
div[data-zeyn-statusbar]Items at the start and end and [data-zeyn-status-live] (role=status, aria-live=polite). zeyn:status (detail.text) from any element of the screen replaces the live text.

8.3 Docking panels

div.z-dock[data-zeyn-dock] is a grid of four section.z-dock-area[data-area] (left, center, bottom, right) and three div.z-dock-bar[role=separator][data-edge] dividers. Sizes are the custom properties --z-dock-left, --z-dock-right (percent of the width) and --z-dock-bottom (percent of the height), 10 to 60. Each area has a role=tablist of draggable button.z-dock-tab[data-panel], an options button and role=tabpanel panels holding the child nodes (paths as in any container; they never change when a panel moves). Side areas without open panels are hidden.

Keyboard: Left and Right (mirrored in right to left pages), Home and End move between the open tabs of an area; Delete closes the focused panel; Shift+F10 or the context menu key opens the area's options (move to another area, close, show closed panels); arrows on a divider resize by 5 percent, Home and End go to the limits. A menu bar entry made with MenuList::panels lists every panel as a menuitemcheckbox. Changes are announced in [data-zeyn-dock-live].

EndpointBodyAnswer
POST /_zeyn/dock{ screen, params, node, save: false }{ layout }: the signed in user's saved layout or null
POST /_zeyn/dock{ screen, params, node, save: true, layout }204; layout: null deletes it

layout is { sizes: { left, right, bottom }, panels: [{ id, area, open, active }] } in tab order. The server checks CSRF, the screen's access and the feature's guards, keeps only the dock's own panels (each once) and clamps the sizes, then stores it in the app's view store (App::datagrid_views) under the tenant, screen and node. Without a store the endpoint answers 404 and without a user 403; the browser then keeps the layout in localStorage.

9. AI agents: MCP and WebMCP

Outside AI agents reach the app through two doors, both off by default and turned on with App::mcp:

SettingEndpoint MCPWebMCP toolsWrites
Mcp::off() (default)404404no
Mcp::read_only()ononno
Mcp::on()ononform saves and API actions that write

.server(false) and .webmcp(false) turn one door off. New projects from zeyn new start with Mcp::read_only().

Why off: the endpoint lets any holder of a valid Authorization header drive the app through a model, and a model can be steered by the data it reads. The framework makes every app ready for agents (tools are generated from the code, nothing to write), but whether an app opens that door, and whether agents may write, is a decision for the people who run it. Read only is one line away.

9.1 Tools

Tools are built from the app for the signed in user:

  • one search tool per grid ({screen}_{node}_search, input: a DataQuery) and, with writes, one save tool per form ({screen}_{node}_save, input: the form values as typed) of every routed screen that opens without params;
  • one tool per published API action (api_{feature}_{action}). An action whose input is a DataQuery reads; every other API action counts as a write.

The description is built from the screen title, the first paragraph of the screen's and the action's /// doc comment and their ai = "..." guidance. Grids and forms the user may not use, and screens and actions marked ai = false, are never listed and are refused when called.

#[action(confirm)] marks an action a model may run only after the user confirms it in the app. /_zeyn/mcp neither lists nor runs such tools (nobody on that channel can confirm). WebMCP tools ask in a modal dialog (zeyn:confirm) and then send confirmed: true with the form; the built in assistant asks in its chat (see docs/assistant.md).

9.2 Endpoints

EndpointDoes
GET /_zeyn/tools?screen&params{ tools: [{ name, description, inputSchema, kind, screen, node, writes, confirm }], texts } for the screen in view. zeyn.js registers them with document.modelContext
POST /_zeyn/mcpJSON-RPC: initialize, tools/list, tools/call. Needs an Authorization header and a signed in user
POST /_zeyn/form with via_agent: trueA WebMCP form save; needs Mcp::on() with WebMCP. confirmed: true only after the user confirmed

Every agent call runs as the user: the screen's access, guards, the action's permission, interceptors, rate limits and the time limit. Agent calls are always audited (via_agent: true) and masked fields are always masked for them, even when the user may reveal them. OpenAPI marks each operation with x-zeyn-ai: false for ai = false, otherwise { hint, confirm, writes }.