Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Webview Protocol

orzma is in early development; this wire format is documented as it is today and may change between releases. The SDKs track these changes for you — prefer them unless you are implementing your own client.

Overview

The Orzma Webview protocol lets a local program running inside an orzma pane render webview content inline in the terminal and exchange messages with the page. It spans three surfaces:

  1. The control socket — a local Unix-socket connection over which a program registers content, manages it, and routes the page back-channel.
  2. APC verbs — terminal escape sequences that mount and unmount registered content at a cell rectangle.
  3. The window.orzma bridge — an in-page JavaScript API the webview uses to call, subscribe to, and emit events to the registering program.

Three actors participate: the registering program (running in a pane), the orzma host, and the webview page. A registration is a Tier 1 (dynamic, runtime-registered) webview — the only kind this protocol describes.

End to end: a program connects to the control socket, registers content and receives an opaque handle together with its first placement instance, writes an ESC _ Omount;n=<instance>,… sequence to display it, and then talks to the page through the window.orzma bridge routed over the same control socket. Unmounting (or disconnecting) tears it down.

Architecture at a glance

sequenceDiagram
    participant P as Registering program
    participant H as orzma host
    participant W as Webview page
    Note over P: reads $ORZMA_SOCK and $ORZMA_TOKEN
    P->>H: hello {token}
    P->>H: register {kind, …}
    H-->>P: {ok, handle, instance}
    P->>H: APC Omount#59;n=instance,r=rows,c=cols
    H->>W: load orzma://handle/…
    W->>H: window.orzma.call
    H->>P: {op: call, reqId, method}
    P->>H: {op: reply, reqId, value}
    H->>W: resolve the Promise
    P->>H: {op: emit, event, payload}
    H->>W: run window.orzma.on handlers
    W->>H: window.orzma.emit
    H->>P: {op: event, …}
    P->>H: APC Ounmount#59;n=instance
    H->>W: remove the webview

The control socket carries every message between the program and the host, the APC verbs carry the mount and unmount, and the page bridge carries the window.orzma messages between the host and the page.

The control socket

Transport

The control socket is a local Unix-domain stream socket speaking NDJSON (on Windows, an AF_UNIX socket, available since Windows 10 1809; the endpoint is a filesystem path on every platform): exactly one JSON object per line, terminated by \n (a trailing \r is tolerated). Each line travels in one direction. The host closes a connection when its first line is longer than 4 KiB or any later line is longer than 32 MiB. The connection is long-lived — it stays open for as long as the program wants its registrations to live.

Discovery

orzma injects two environment variables into every pane’s PTY. A program reads them from its own environment:

  • $ORZMA_SOCK — the absolute path to the control socket. Connect to this path verbatim; do not reconstruct it.
  • $ORZMA_TOKEN — the per-pane handshake token. Treat it as opaque (it is currently orzma: followed by 26 lowercase base32 characters, but do not parse it).

If either variable is absent, the program is not running inside an orzma pane, or orzma could not open its control socket, and the program cannot use the protocol.

Peer authentication

The host restricts the control socket to orzma’s own user. On Unix it checks that the connecting peer’s user id equals its own and silently drops the connection otherwise. On Windows the socket lives in a directory whose DACL grants access to the current user only, so other users cannot connect at all. Either way, only processes running as the same user can reach the handshake.

Handshake

The first line a program sends MUST be a hello carrying $ORZMA_TOKEN:

{ "op": "hello", "token": "orzma:mzxw6ytboi3tmnrqgq2tgmzvgm" }

The token binds the connection to the pane it was issued for. If the first line is not a valid hello, or the token does not resolve, the host closes the connection without a reply. A successful hello gets no reply either; the program may send requests right after it. A second hello on an already-handshaked connection is ignored.

Reply vs. push

After the handshake, two kinds of line arrive from the host on the same connection, and a client must tell them apart:

  • A request reply is the only host line with no op field. Both register and new_instance are answered this way, and either can also reply {"ok":false,"error":"…"}.
  • Every host-initiated push (call, event, compositing, focus_changed) carries an op field.

So: a line with an op is a push; a line without one is the reply to your oldest outstanding request. This is the one framing rule a from-scratch client must get right.

Request ordering

register and new_instance are processed one at a time per connection, and each reply arrives in request order. Neither carries a request id, so correlation is positional: a client matches replies to its pending requests by their order. (The back-channel call/reply pair below uses an explicit reqId instead.)

After the handshake, the host skips a line that is not valid JSON, names an unknown op, or lacks or mistypes a required field, without replying and without closing the connection; unknown extra fields are ignored. A malformed register or new_instance therefore gets no reply, so a client that matches replies by position must send only well-formed requests.

Program → host messages

Every program line carries an op:

opFieldsMeaning
hellotokenHandshake; first line only.
registerkind + per-kind fieldsRegister content; mints a handle and its first instance.
new_instancehandleMint an additional instance on a handle this connection owns.
unregisterhandleRelease a handle owned by this connection; removes its mounted views.
replyreqId, ok, value?, error?Answer a host call (use the call’s reqId).
emithandle, event, payloadPush an event to every page mounted from the handle (delivered to window.orzma.on). Ignored for a url view without the bridge.
focusinstance (string or null)Set app-owned focus to a mounted, interactive placement, or null to blur. Focusing a placement also makes its pane the active pane; a blur leaves the active pane unchanged.
navigateinstance, actionNavigate one mounted placement in place.
mountinstance, row, col, rows, colsMount one placement at a 0-based cell of the pane’s active screen, the socket form of the APC mount (see below).
unmountinstanceRemove one placement, whether the socket mount or the APC mount placed it.
set_forward_keyshandle, keys (array of chords)Replace the forward keys of a handle this connection owns, on the registration and on every mounted placement; later mounts carry the new list. No reply; a handle this connection does not own is ignored.

Only register and new_instance are answered. The host silently ignores any other op it refuses, such as one naming a handle or instance this connection does not own.

navigate.action is one of the strings "back", "forward", "reload", or the object {"to":"<http(s) url>"} (to is valid only on a url view).

Register kinds

register carries a kind discriminator and its fields:

kindRequiredOptional (default)Served at
dirroot (absolute dir path), entry (safe relative path, e.g. index.html)interactive (true), forward_keys ([]), preload ([])orzma://<handle>/<entry>
inlinehtml (full document, ≤ 4 MiB)interactive (true), forward_keys ([]), preload ([])orzma://<handle>/index.html
urlurl (http/https only)interactive (true), bridge (false), forward_keys ([]), preload ([])the remote URL directly (no orzma:// origin)
  • interactive — whether the mounted view accepts pointer/keyboard input.
  • forward_keys — the initial forward keys; replace them later with set_forward_keys.
  • bridge (url only) — opt into the window.orzma back-channel. dir and inline are always bridged; a url view is bridged only with bridge:true.
  • preload — an array of JavaScript source strings injected before the page’s own scripts, after the host bridge when the view is bridged. It is honored for every kind, including a url view without the bridge.

Focus

Outside vi mode, a pointer press inside a mounted interactive view’s rect gives that view keyboard focus and makes its pane the active pane, and a press on the terminal outside every view’s rect gives the keyboard back to the terminal. While a view holds focus, the host delivers keys to the page, except the view’s forward keys, which go to the pane’s PTY instead, and orzma’s <Leader> shortcuts and release-focus shortcut, which still run.

A focus op moves focus to a mounted, interactive placement this connection owns, or, with null, takes it back from whichever view in this connection’s pane holds it. A focus naming an unmounted or non-interactive placement is ignored. The active pane does not change on a blur.

Every change is reported to the program that registered the placement with a focus_changed push, whatever caused it: a click, a focus op, vi mode, the release-focus shortcut, a pane switch, or an unmount. A program whose TUI needs every key for a while — a search line, an address bar — sends a focus op with null when it starts and, if it wants, focuses the page again when it ends.

Forward keys

forward_keys lists key chords the host passes through to the pane’s PTY instead of letting the focused webview consume them: a matching key reaches the program and never the page. register carries the initial list; set_forward_keys replaces it wholesale. Each chord is:

{ "mods": ["alt"], "key": "h" }

mods is any subset of "alt", "ctrl", "shift", "meta". key is one of:

  • a lowercase letter a–z, a digit 0–9, tab, backtab, f1–f12, esc, " " (space), up, down, left, right, pageup, pagedown, home, end, enter, backspace, delete — matched against the physical key with the exact modifier set. backtab names the same key as tab, so include "shift" in mods to match Shift+Tab;
  • one ASCII punctuation character such as /, ?, [, ], : — matched against the character the key produced, whichever key produced it, with Shift ignored and the other modifiers exact. Characters typed through a dead key or AltGr do not match. On macOS, a character typed with the Option key does not match a punctuation chord unless option_as_alt is in effect for that side.

Unrecognized chords are silently ignored. The key-up of a forwarded key may still reach the page.

Host → program messages

Every host push carries an op:

opFieldsMeaning / response
callhandle, instance, reqId, method, paramsA page window.orzma.call(method, params). Respond with a reply carrying the same reqId.
eventhandle, event, payloadA page window.orzma.emit(event, payload). Fire-and-forget; no response.
compositinghandle, instance, active (bool)The placement first appeared on screen (true), or was unmounted after that (false). Only a view with the bridge gets this push.
focus_changedhandle, instance, focused (bool)The placement gained (true) or lost (false) keyboard focus. A move between placements sends false for the old one before true for the new one.

call names the instance whose page called; event does not, because a program reads events per handle rather than per placement. A program running two placements of one handle can therefore tell which page called it, but not which page emitted an event.

Two directional details that are easy to get wrong:

  • emit vs. event. A page’s window.orzma.emit(name, …) arrives at the program as op:"event". A program’s own emit message (op:"emit") is delivered to pages’ window.orzma.on(name, …). Same idea (“named event”), two op values depending on direction.
  • urlChanged. For a url view registered with bridge:true, the host reports top-level address changes as an op:"call" with method:"urlChanged" and params:{"url":"<new>"}. Despite the call shape it is fire-and-forget — any reply is discarded. Use it to track page-driven navigation.

Request replies & error codes

A successful register replies {"ok":true,"handle":"<handle>","instance":"<instance>"} — the handle owns the registration, and the instance is its first placement. A successful new_instance replies {"ok":true,"instance":"<instance>"}. A rejected request of either kind replies {"ok":false,"error":"<code>"}:

errorCause
invalid_rootdir.root is not an absolute path to an existing directory.
unsafe_entrydir.entry is empty or absolute, or contains a .. component or a leading . component.
html_too_largeinline.html exceeds 4 MiB.
invalid_urlurl.url does not parse or has no host.
unsupported_schemeurl.url is not http/https.
unknown_handlenew_instance.handle names no live registration.
not_ownernew_instance.handle is registered, but by another connection.
owner_goneThe register request’s pane has already closed.
internalThe host failed to process the request.

Handle semantics

A handle is opaque, unique per registration, and lowercase: 128 CSPRNG bits base32-encoded over the alphabet a-z2-7, which keeps it spellable as a URL host. That encoding is unpadded, so a handle is always 26 characters. Treat it as a token: do not parse it. Each handle owns one isolated orzma://<handle>/ origin, and it is what unregister, emit, set_forward_keys, and new_instance address. A handle is never mounted — a mount addresses an instance.

Instance semantics

An instance is one placement of a registration, and it is the unit mount, unmount, focus, and navigate address. Its wire spelling is exactly 32 lowercase hex digits (128 CSPRNG bits); any other spelling is malformed. Only the host mints instances — register mints the first, new_instance each additional one — so uniqueness is structural and nothing on the wire negotiates it.

An instance stays valid for as long as its handle is registered, whether or not it is currently mounted. Mounting an instance that is already live moves and resizes its rectangle to the new mount and leaves the page untouched; mounting one after it was unmounted builds the page again from scratch. Every instance of a handle serves that handle’s registered content, and each one mounts independently.

Example exchange

Program-to-host lines are marked C→S, host-to-program lines S→C:

C→S {"op":"hello","token":"orzma:mzxw6ytboi3tmnrqgq2tgmzvgm"}
C→S {"op":"register","kind":"inline","html":"<!doctype html><body>hi</body>"}
S→C {"ok":true,"handle":"nf2k7q5w3x3m5a6b2c4d6e7fgh","instance":"3f5a9c02d1e84b7690ab3cde12f45678"}
S→C {"op":"call","handle":"nf2k7q5w3x3m5a6b2c4d6e7fgh","instance":"3f5a9c02d1e84b7690ab3cde12f45678","reqId":"0","method":"save","params":{"text":"hi"}}
C→S {"op":"reply","reqId":"0","ok":true,"value":{"saved":true}}
C→S {"op":"emit","handle":"nf2k7q5w3x3m5a6b2c4d6e7fgh","event":"tick","payload":{"n":1}}
C→S {"op":"new_instance","handle":"nf2k7q5w3x3m5a6b2c4d6e7fgh"}
S→C {"ok":true,"instance":"a1b2c3d4e5f60718293a4b5c6d7e8f90"}

APC webview verbs — mount / unmount

Once an instance is minted, the program mounts it by writing an APC escape sequence to its terminal. The sequence is framed ESC _ <payload> ST, where ST (string terminator) is ESC \. Unlike an OSC, a BEL does not terminate an APC — it is taken as payload data. The payload opens with O (orzma), so a sequence another program owns — kitty’s G, for example — is left alone. In raw bytes:

mount:    \x1b_Omount;n=<instance>,r=<rows>,c=<cols>\x1b\
unmount:  \x1b_Ounmount;n=<instance>\x1b\

The payload is at most 1024 bytes. Keep it to ASCII: the terminal does not decode UTF-8 inside an APC. It silently drops some bytes of a multi-byte character, and a byte in the range 0x80–0x9F, which many multi-byte characters contain, ends the sequence early and shows the rest of it as text.

Socket form

A PTY that re-renders its child’s output rather than passing it through — ConPTY on Windows — drops APC strings, so every host also accepts the same two verbs as control-socket ops:

{"op":"mount","instance":"<instance>","row":<row>,"col":<col>,"rows":<rows>,"cols":<cols>}
{"op":"unmount","instance":"<instance>"}

row and col are the 0-based cell of the pane’s active screen that the rect’s top-left corner occupies: the cell an APC mount reaches by first moving the cursor with CUP row+1;col+1. Unlike CUP, they are absolute even when DECOM origin mode is on. rows and cols obey the APC bounds (1–200, 1–400). The host drops a mount naming an instance the connection does not own or a size out of range; the terminal refuses a cell outside the grid or a mount past the per-terminal placement cap exactly as it refuses an APC mount. The socket unmount names one instance; there is no unmount-all form. The ratatui_orzma SDK sends the socket form on Windows and the APC form elsewhere.

mount

ESC _ O mount ; n=<instance>,r=<rows>,c=<cols> ST
  • instance — an instance from register or new_instance; exactly 32 lowercase hex digits.
  • rows — decimal 1–200. cols — decimal 1–400. Write plain decimal digits.

All three keys are required and order-independent (c=80,n=<instance>,r=24 is the same mount as n=<instance>,r=24,c=80). A repeated key, an unknown key — the retired v= included — a missing n / r / c, or an empty params section (Omount;) is malformed.

The placement occupies a rows×cols rectangle of terminal cells, inline at the cursor.

unmount

ESC _ O unmount [ ; n=<instance> ] ST
  • No params section → unmount every placement on this terminal, on both screens, whichever program in the pane mounted it.
  • n= → unmount that one placement.

n is the only key an unmount accepts, so no key-ordering rule applies. An empty params section (Ounmount;) is malformed, as are an empty value (Ounmount;n=) and the retired v= key.

Ownership and malformed sequences

A mount takes effect only in the pane whose $ORZMA_TOKEN registered the instance’s handle — a program mounts its own instances in its own pane. An instance the host never minted, or one whose handle belongs to another pane, is silently dropped, as is any malformed sequence (a bad instance spelling, out-of-range dimensions, an unknown or repeated key); the host reports no error.

A mount the terminal accepts but cannot place — the per-terminal placement cap of 12 is full — is also dropped, and the host logs it at debug level. The cap counts both screens, so it is reached before the renderer runs out of overlay slots.

Example

Mount instance 3f5a9c02d1e84b7690ab3cde12f45678 as a 24×80 placement, then unmount it:

\x1b_Omount;n=3f5a9c02d1e84b7690ab3cde12f45678,r=24,c=80\x1b\
\x1b_Ounmount;n=3f5a9c02d1e84b7690ab3cde12f45678\x1b\

The orzma:// origin

dir and inline registrations are served from a per-handle origin orzma://<handle>/. A request for an empty path resolves to index.html. The scheme is standard, secure, CORS-enabled, fetch-enabled, and display-isolated, so normal fetch, ES modules, and same-origin requests work within the handle’s origin. Each handle is its own isolated origin.

  • dir — files are served from the registered root. Requests that escape the root — a .. or . path component, an absolute path, or their percent-encoded forms — are rejected; each file is capped at 64 MiB; the content type is inferred from the file extension. The check looks at the path only, so a symlink inside the root is followed wherever it points.
  • inline — the single registered document is served only at index.html; any subresource request returns 404. Use dir for multi-file content.
  • url — the remote http(s) page is loaded directly and has no orzma:// origin.

The window.orzma bridge

Bridged webviews expose a frozen window.orzma object to page scripts. dir and inline views are always bridged; a url view is bridged only when registered with bridge:true. A page should feature-detect before using it.

API

MethodReturnsMeaning
call(method, params?)PromiseInvoke a program method; resolves with the program’s reply value, rejects with Error(error).
on(event, handler)voidSubscribe to a program emit.
off(event, handler)voidRemove a handler by reference.
emit(event, payload?)voidSend a one-way event to the program (arrives as op:"event").

A call has no client-side timeout — if the program never replies, the Promise stays pending. The host injects a rejection when it cannot route the call: no_owner (the page has no bridge, or its placement was unmounted), owner_unavailable (the program’s connection can no longer be written to, or orzma’s multiplexer has stopped), or owner_disconnected (the program disconnected with the call in flight). A call whose method is not a string rejects at once with a TypeError.

The bridge subscribes one handler of its own: a program emit named scroll with the payload {"action":"<action>"}, where <action> is down, up, halfDown, halfUp, pageDown, pageUp, top, or bottom, scrolls the page. It does nothing when the page has registered its own scroll handler.

Binary round-trip

A top-level Uint8Array round-trips through the bridge — it is tagged {"__u8":"<base64>"} on the wire and decoded back to a Uint8Array. This applies to a call’s params, a resolved value, and an event payload. Bytes nested inside an object or array are not tagged and are silently lost. Pass binary as the top-level value, not as a field.

Example

Using the @orzma/web client:

import { orzma, isOrzmaAvailable } from "@orzma/web";

if (isOrzmaAvailable()) {
  // Request / response — annotate the reply type.
  const res = await orzma.call<{ saved: boolean }>("save", { text: "hi" });

  // Subscribe to a program event — annotate the payload.
  orzma.on<{ n: number }>("tick", (payload) => console.log(payload.n));

  // One-way event to the program.
  orzma.emit("ready", { ok: true });
}

Lifecycle & teardown

  • unregister{handle} releases a handle, invalidates every instance minted from it, and removes its mounted views.
  • Closing the control connection purges all of that program’s handles, removes their views, and rejects every in-flight call with owner_disconnected. The host treats the end of the program’s sending side as a close, so a program must not shut down its write half while it wants its registrations to live.
  • When the pane a program runs in closes, the host releases every registration made from that pane and removes its views, although the program’s connection stays open. The program gets compositing false and focus_changed false for the placements that had them. Afterwards a register fails with owner_gone, and requests naming the released handles or instances are ignored or fail with unknown_handle.
  • The terminal also unmounts a placement on its own when the row it is anchored to leaves the terminal (it scrolls out of the scrollback, the terminal is reset, or a resize drops it), and when the program leaves the alternate screen the placement was mounted on. The instance stays valid and can be mounted again. The program learns of this only through compositing false (for a bridged view) and focus_changed false (if the view held focus).
  • For a view with the bridge, the compositing push reports the first time one placement appears on screen (active:true), which can be before the page has painted, and its unmount after that (active:false). It names both the handle and the instance.

Security model

  • Same user only. The host restricts the control socket to orzma’s own user: by peer user id on Unix, by the socket directory’s DACL on Windows.
  • Scoped to one pane. A connection’s token binds it to the pane that issued $ORZMA_TOKEN. Control-socket ops act only on registrations the connection made itself; an APC mount takes effect only in the pane that registered the instance’s handle, and an APC unmount acts on any placement in the pane it is written to.
  • Unguessable, isolated identifiers. Tokens, handles, and instances are all 128-bit CSPRNG values, and each handle is its own orzma:// origin.
  • Authorized replies. Back-channel reqIds are a shared, monotonic counter and therefore guessable, so the host authorizes a reply by its originating connection: a program replaying another connection’s reqId can neither settle nor drop that call.

SDKs

Prefer a ready-made client over implementing the wire protocol directly:

  • ratatui_orzma — Rust SDK for the program side (a ratatui widget plus a back-channel RPC handler).
  • @orzma/web — TypeScript client for the page-side window.orzma bridge.

Building Webview Apps walks through both.