Part I — The wire protocol
1. Overview
Section titled “1. Overview”Snug connects agents to apps: LLM-authored single-file HTML micro-apps run in a sandboxed iframe and think through the host’s agent at runtime, over two coupled contracts:
- Frames — postMessage messages between the app iframe and the host runner.
- Chat envelope — the tagged message a host sends to its own agent endpoint for an app-originated turn, and the JSON-only reply contract for the agent.
Every frame carries v: 1; the chat envelope carries snug: 1. The wire protocol is
version 1 and has been additively extended, never broken, since v0.1.
2. Frames
Section titled “2. Frames”Thirteen frame types are defined, and all thirteen are published as JSON Schemas — the nine core frames since v0.1, the net pair and the open-url pair with v0.3 (Appendix C). The strict pairs’ refinement rules are carried by this prose, not by the schemas, which JSON Schema cannot express (§3).
| Type | Direction | Purpose |
|---|---|---|
snug:app-announce | app → host | Self-describing metadata on mount: appId (≤128), displayName (≤80), optional description (≤400), iconEmoji (≤8), iconColor (≤32). Hosts ack with snug:host-ready. |
snug:host-ready | host → app | On iframe load AND as announce-ack (idempotent): instanceId, protocolVersions, capabilities, theme, locale?. |
snug:app-message | app → host | An agent request: requestId, instanceId, appId, action (≤128), structured payload?, state?, responseSchema?. |
snug:app-cancel | app → host | Abort an in-flight requestId. |
snug:app-response | host → app | Streaming / final / error, per R3. Three shapes: cumulative text (+ optional seq); a final data object; or an error. |
snug:db-request / snug:db-response | app ↔ host | Host-brokered per-app storage: op ∈ exec, export, import, kvGet, kvSet. |
snug:net-request / snug:net-response | app ↔ host | The governed network capability (§3). The iframe still has zero network of its own (C2); this pair is the app’s only path to the network, and the host is the only caller. |
snug:open-url-request / snug:open-url-result | app ↔ host | Host-mediated navigation (§4). The host opens the user’s real browser after its own confirm dialog, on a user gesture; the sandbox gains no capability. |
snug:host-event / snug:app-event | either | Open additive channel (theme-change, visibility, connection-event, resize {height}, …); unknown events ignored. Subject to rule R7. |
Capability advertisement. snug:host-ready.capabilities requires streaming, db,
and auth booleans; net?: boolean and openUrl?: boolean are optional additive flags
(R2-safe — pre-feature frames still parse). Absence of a flag is how an app knows to render
a fallback rather than a broken control.
3. The net frames
Section titled “3. The net frames”snug:net-request is strict (an unknown key rejects the frame — the one deliberate
departure from tolerant parsing, because this frame’s fields become a real network request):
url(1–4096),method ∈ NET_METHODS=GET, HEAD, POST, PUT, PATCH, DELETEheaders?(names 1–128, values ≤4096),body?(≤262 144 chars)- A
bodyon GET/HEAD is rejected. - Any header whose lowercase name is in
STRIP_HEADERS(authorization,cookie,set-cookie,x-api-key,proxy-authorization) makes the whole frame malformed — an app cannot send a credential-shaped header across the bridge at all (C1). - There is no
appIdseat, by design. The net binding is host-assigned, likedbNamespace: the host knows which app a frame came from by message source (R4), and an identity field would only exist to be forged.
snug:net-response carries status (100–599), whitelist-filtered headers, body, and
truncated? — or an envelope error. The response-header whitelist is
content-type, content-length, cache-control, etag, last-modified, retry-after, link plus
the x-ratelimit-* glob; set-cookie never crosses.
What the host MUST do between these two frames — the ceiling, the confirm gate, the injection, the scrub — is Part III (§14). The frames only carry the request and the answer.
4. The open-url frames
Section titled “4. The open-url frames”snug:open-url-request is strict: url (1–2048), which MUST parse as a URL, MUST be
https:, and MUST carry no username or password (the phishing shape is refused at the
schema). There is no target, no window-features seat, and no navigation primitive — the
frame is a request that the host open the URL in the user’s real browser.
Host obligations: show the full URL in the host’s own confirm dialog, open only on a
user gesture, and answer with snug:open-url-result — status ∈ opened, declined, refused
(+ reason? ≤300). C2 is unchanged: the sandbox never gains allow-popups, and a host
that advertises capabilities.openUrl: true is promising exactly this mediated flow.
5. Normative rules
Section titled “5. Normative rules”- R1 Versioning. Every frame carries
v: 1; the chat envelope carriessnug: 1. Unsupported versions are rejected withUNSUPPORTED_VERSION. Parse failures surface arequestIdrecovered from the raw frame when it carried a plausible string id (1–128); hosts answerUNSUPPORTED_VERSION/MALFORMEDon the wire only in that case (never otherwise).snug:host-ready.protocolVersionsadvertises support. - R2 Additivity. A frame with a valid
vbut unrecognizedsnug:*type MUST be silently ignored. Unknown fields on known frames MUST be ignored (the strict net and open-url frames are the stated exceptions: their fields become real-world effects, so unknown keys reject). Thesnug:type prefix and the event namespaces are reserved. - R3 Terminal frame. Every accepted
requestIdreceives exactly one terminalsnug:app-response(ok:true, streaming:falseorok:false).streaming:trueframes are cumulative prose, display-provisional; the terminal frame is authoritative. Hosts MAY suppress streaming for schema-constrained requests. (mode: 'delta'+seqreserved.) - R4 Identity. Hosts route by
event.source(sandboxed iframes have a null origin;targetOriginis necessarily'*'). The host mintsinstanceId(delivered insnug:host-ready); apps echo it in every request. A newsnug:app-announcefrom the same iframe invalidates in-flight work (SUPERSEDED).appIdis display metadata, not a security principal.requestIdMUST be unique per instance. - R5 Error codes.
error.codeis an open string; known codes:PARSE_FAILED,THREAD_CONFLICT,NETWORK_ERROR,RESET_FAILED,CANCELLED,SUPERSEDED,UNSUPPORTED_VERSION,CONSENT_REQUIRED(reserved),AUTH_REQUIRED(reserved),HOST_ERROR. The net capability adds its own registry (Appendix A):NET_INVALID_REQUEST,NET_NOT_APPROVED,NET_IMPORTED_UNAPPROVED,NET_AMBIGUOUS_CONNECTION,NET_SCHEME_BLOCKED,NET_HOST_BLOCKED,NET_SSRF_BLOCKED,NET_CONFIRM_DENIED,NET_REDIRECT_BLOCKED,NET_SIZE_EXCEEDED,NET_FETCH_FAILED,NET_AUTH_FAILED(+NET_SCRUBBED_HEADER_STRIPPED, reserved). Receivers treat unknown codes perretryableand render asHOST_ERROR. - R6 Limits. Frames ≤ 256 KiB, except two larger size classes:
db-request/db-response≤ 8 MiB (so a base64-encoded 5 MiB artifact round-trips through the db bridge), andnet-request/net-response≤MAX_NET_FRAME_BYTES= 1 MiB + 64 KiB (a 1 MiB response body plus envelope margin; an oversized net answer becomes a terminalNET_SIZE_EXCEEDED, never a silent drop). Net request bodies ≤ 256 KiB; net response bodies ≤ 1 MiB, capped while reading. Artifacts ≤ 5 MiB;rawExcerpt≤ 200 chars; announce strings capped (displayName 80, description 400). Parse-failure budget: 3 consecutive, then the host requires an explicit user reset. Thread-conflict backoff: 100/250/500 ms. - R7 Push hints (since v0.3). A host-initiated push (
snug:host-event) carries references, never content — a doorbell, not a delivery. The app answers a hint with its own governed reads, and the host rebuilds every field of a hint before forwarding. Two frame-layer facts force this shape and make it normative rather than stylistic: host-event frames ride the ordinary 256 KiB class and an oversized frame is dropped silently, and host-event frames carry noinstanceId, so a stale sender is indistinguishable from a live one. With hints, a stale or dropped event costs one redundant refetch and can never inject state. - Security (C1/C2). Credentials never enter the iframe, the LLM payload, or a
publisher. Hosts MUST strip
authorization,cookie,set-cookie,x-api-key,proxy-authorizationfrom any app-originated request at the envelope boundary. Iframes runsandbox="allow-scripts"only — storage is therefore host-brokered via db frames, and the network is host-brokered via net frames. The runner’s CSP permits script/style loads from a fixed CDN allowlist (CDN_ALLOWLIST:cdn.jsdelivr.net,cdnjs.cloudflare.com,unpkg.com) and connections from none; the list is never widened at runtime.
6. Chat envelope
Section titled “6. Chat envelope”Wire form: [SNUG_APP_REQUEST]\n{json} where json = {snug: 1, appId, instanceId, requestId, action, payload?, state?, responseSchema?}. Detection = tag prefix and the
snug: 1 marker — two independent signals, so ordinary chat content quoting the tag is
never processed as an app request. Servers SHOULD skip thread history for app requests (the
envelope is self-contained via state) and MUST apply the C1 header strip.
Agent reply contract: the agent responds with ONLY a JSON object (a human-readable
message field is recommended). Hosts parse with graduated tolerance (raw parse → fenced
block → balanced-object extraction), reject null/array/scalar, and convert failures to
PARSE_FAILED frames carrying rawExcerpt (≤200) and attemptsRemaining.