Skip to content
Specification 1.0 — normative. One section (§17, standing approvals) is explicitly provisional and so marked.

Part I — The wire protocol

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:

  1. Frames — postMessage messages between the app iframe and the host runner.
  2. 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.

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

TypeDirectionPurpose
snug:app-announceapp → hostSelf-describing metadata on mount: appId (≤128), displayName (≤80), optional description (≤400), iconEmoji (≤8), iconColor (≤32). Hosts ack with snug:host-ready.
snug:host-readyhost → appOn iframe load AND as announce-ack (idempotent): instanceId, protocolVersions, capabilities, theme, locale?.
snug:app-messageapp → hostAn agent request: requestId, instanceId, appId, action (≤128), structured payload?, state?, responseSchema?.
snug:app-cancelapp → hostAbort an in-flight requestId.
snug:app-responsehost → appStreaming / final / error, per R3. Three shapes: cumulative text (+ optional seq); a final data object; or an error.
snug:db-request / snug:db-responseapp ↔ hostHost-brokered per-app storage: op ∈ exec, export, import, kvGet, kvSet.
snug:net-request / snug:net-responseapp ↔ hostThe 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-resultapp ↔ hostHost-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-eventeitherOpen 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.

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, DELETE
  • headers? (names 1–128, values ≤4096), body? (≤262 144 chars)
  • A body on 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 appId seat, by design. The net binding is host-assigned, like dbNamespace: 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.

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-resultstatus ∈ 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.

  • R1 Versioning. Every frame carries v: 1; the chat envelope carries snug: 1. Unsupported versions are rejected with UNSUPPORTED_VERSION. Parse failures surface a requestId recovered from the raw frame when it carried a plausible string id (1–128); hosts answer UNSUPPORTED_VERSION/MALFORMED on the wire only in that case (never otherwise). snug:host-ready.protocolVersions advertises support.
  • R2 Additivity. A frame with a valid v but unrecognized snug:* 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). The snug: type prefix and the event namespaces are reserved.
  • R3 Terminal frame. Every accepted requestId receives exactly one terminal snug:app-response (ok:true, streaming:false or ok:false). streaming:true frames are cumulative prose, display-provisional; the terminal frame is authoritative. Hosts MAY suppress streaming for schema-constrained requests. (mode: 'delta' + seq reserved.)
  • R4 Identity. Hosts route by event.source (sandboxed iframes have a null origin; targetOrigin is necessarily '*'). The host mints instanceId (delivered in snug:host-ready); apps echo it in every request. A new snug:app-announce from the same iframe invalidates in-flight work (SUPERSEDED). appId is display metadata, not a security principal. requestId MUST be unique per instance.
  • R5 Error codes. error.code is 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 per retryable and render as HOST_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), and net-request/net-responseMAX_NET_FRAME_BYTES = 1 MiB + 64 KiB (a 1 MiB response body plus envelope margin; an oversized net answer becomes a terminal NET_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 no instanceId, 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-authorization from any app-originated request at the envelope boundary. Iframes run sandbox="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.

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.