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:
- The control socket — a local Unix-socket connection over which a program registers content, manages it, and routes the page back-channel.
- APC verbs — terminal escape sequences that mount and unmount registered content at a cell rectangle.
- The
window.orzmabridge — 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 currentlyorzma: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
opfield. Bothregisterandnew_instanceare answered this way, and either can also reply{"ok":false,"error":"…"}. - Every host-initiated push (
call,event,compositing,focus_changed) carries anopfield.
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:
op | Fields | Meaning |
|---|---|---|
hello | token | Handshake; first line only. |
register | kind + per-kind fields | Register content; mints a handle and its first instance. |
new_instance | handle | Mint an additional instance on a handle this connection owns. |
unregister | handle | Release a handle owned by this connection; removes its mounted views. |
reply | reqId, ok, value?, error? | Answer a host call (use the call’s reqId). |
emit | handle, event, payload | Push an event to every page mounted from the handle (delivered to window.orzma.on). Ignored for a url view without the bridge. |
focus | instance (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. |
navigate | instance, action | Navigate one mounted placement in place. |
mount | instance, row, col, rows, cols | Mount one placement at a 0-based cell of the pane’s active screen, the socket form of the APC mount (see below). |
unmount | instance | Remove one placement, whether the socket mount or the APC mount placed it. |
set_forward_keys | handle, 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:
kind | Required | Optional (default) | Served at |
|---|---|---|---|
dir | root (absolute dir path), entry (safe relative path, e.g. index.html) | interactive (true), forward_keys ([]), preload ([]) | orzma://<handle>/<entry> |
inline | html (full document, ≤ 4 MiB) | interactive (true), forward_keys ([]), preload ([]) | orzma://<handle>/index.html |
url | url (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 withset_forward_keys.bridge(urlonly) — opt into thewindow.orzmaback-channel.dirandinlineare always bridged; aurlview is bridged only withbridge: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 aurlview 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 digit0–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.backtabnames the same key astab, so include"shift"inmodsto 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 unlessoption_as_altis 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:
op | Fields | Meaning / response |
|---|---|---|
call | handle, instance, reqId, method, params | A page window.orzma.call(method, params). Respond with a reply carrying the same reqId. |
event | handle, event, payload | A page window.orzma.emit(event, payload). Fire-and-forget; no response. |
compositing | handle, 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_changed | handle, 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:
emitvs.event. A page’swindow.orzma.emit(name, …)arrives at the program asop:"event". A program’s ownemitmessage (op:"emit") is delivered to pages’window.orzma.on(name, …). Same idea (“named event”), twoopvalues depending on direction.urlChanged. For aurlview registered withbridge:true, the host reports top-level address changes as anop:"call"withmethod:"urlChanged"andparams:{"url":"<new>"}. Despite thecallshape it is fire-and-forget — anyreplyis 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>"}:
error | Cause |
|---|---|
invalid_root | dir.root is not an absolute path to an existing directory. |
unsafe_entry | dir.entry is empty or absolute, or contains a .. component or a leading . component. |
html_too_large | inline.html exceeds 4 MiB. |
invalid_url | url.url does not parse or has no host. |
unsupported_scheme | url.url is not http/https. |
unknown_handle | new_instance.handle names no live registration. |
not_owner | new_instance.handle is registered, but by another connection. |
owner_gone | The register request’s pane has already closed. |
internal | The 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 fromregisterornew_instance; exactly 32 lowercase hex digits.rows— decimal1–200.cols— decimal1–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 registeredroot. 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 atindex.html; any subresource request returns 404. Usedirfor multi-file content.url— the remotehttp(s)page is loaded directly and has noorzma://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
| Method | Returns | Meaning |
|---|---|---|
call(method, params?) | Promise | Invoke a program method; resolves with the program’s reply value, rejects with Error(error). |
on(event, handler) | void | Subscribe to a program emit. |
off(event, handler) | void | Remove a handler by reference. |
emit(event, payload?) | void | Send 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
callwithowner_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
compositingfalseandfocus_changedfalsefor the placements that had them. Afterwards aregisterfails withowner_gone, and requests naming the released handles or instances are ignored or fail withunknown_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
compositingfalse(for a bridged view) andfocus_changedfalse(if the view held focus). - For a view with the bridge, the
compositingpush 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 thehandleand theinstance.
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 APCmounttakes effect only in the pane that registered the instance’s handle, and an APCunmountacts 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 areplyby its originating connection: a program replaying another connection’sreqIdcan 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-sidewindow.orzmabridge.
Building Webview Apps walks through both.