LIP: the Lets Interaction Protocol v1

The contract between Claude, the host and the window. Everything that crosses a boundary is a JSON message envelope; uniques are static web apps that only ever speak LIP, and the host never executes unique code server-side. That is what makes third-party uniques safe to install from git.

Parties

PartyRole
Claude (the model, through the lets MCP tools)reasons, reacts, drives
Host (the lets engine, a local Node process)routes, persists, protects
Window (a unique: a web UI opened in a browser pane)shows, listens, lets the human point

The protocol is designed for mediums that do not exist yet: a unique can be a graph board today, a 3D scene, a voice ring, a haptic strip or an AR overlay tomorrow. Nothing in LIP assumes a screen, only events the human produced and commands the model issues, both carried as typed, versioned JSON.

1. Envelope

{
  "lip": 1,
  "id": "ev_01J9Q0Z8M3K7X4N2",
  "ts": 1760000000000,
  "dir": "up",
  "from": "unique:graph#w1",
  "type": "graph.node.select",
  "data": { "ids": ["n3"] },
  "re": "cm_01J9Q0Z7...",
  "ttl": 30000
}
FieldReqMeaning
lipyesProtocol major version. A receiver that does not speak the major MUST answer lip.error{code:"unsupported"}.
idyesUnique id (ev_ for events, cm_ for commands, ak_ acks). Time-sortable.
tsyesUnix ms at the sender.
diryes"up" = window → host → Claude (an event). "down" = Claude → host → window (a command).
fromyesunique:<name>#<windowId>, claude, host, or hook.
typeyesDotted <domain>.<noun>[.<verb>], lowercase, a-z0-9_.
datanoThe payload, any JSON. Keep it small; put blobs on disk and send paths.
renoId of the message this one answers (ack, reply, stream chunk).
ttlnoMs the message stays meaningful. The host drops expired commands instead of delivering them late.
tono(down only) windowId or unique:<name>; default = every window of the unique the command belongs to.
seqno(down, streams) chunk index; done:true closes the stream.

Batches: a JSON array of envelopes is accepted anywhere a single envelope is.

2. Domains

DomainOwnerPurpose
lip.*protocolhello / ready / ack / error / ping
session.*host + shellWindow lifecycle (opened, closed, focus, blur, listening) and handoff (close, closing, stay)
entity.*shellThe companion ("you"): summon, dismiss, click, say, mood, suggest, move, profile
chat.*shellChat view: open, close, message, say (stream)
component.*shellDynamic components: create, update, remove, action
media.*shellImages / files produced by the human (paste, drop, upload)
ui.*shellToast, theme, layout hints
<unique>.*the uniquee.g. graph.*. Declared in the unique's manifest.

Reserved domains cannot be redeclared by a unique. A unique MAY emit shell-domain events through the shell SDK, never by forging envelopes.

3. Core messages

3.1 Protocol

TypeDirData
lip.hellodown{windowId, unique, version, caps[], hostVersion}: first message on an SSE stream
lip.readyup{unique, version, caps[], viewport:{w,h}, ua}: the window finished booting
lip.ackboth{ok, error?}, re = acked id. Commands flagged ack:true are acked by the window
lip.errorboth{code, message, re?}
lip.ping / lip.pongbothLiveness

3.1b Session: lifecycle and handoff

TypeDirData
session.opened / session.closedup (host){windowId, unique}: a window connected for the first time / went away for good
session.focus / session.blurup{}: the tab became visible / hidden
session.listeningdown (host){on, at}: whether Claude is blocked in listen right now (the shell shows "listening" vs "away")
session.close 0.4.0down{reason:"handoff"|"idle"|"claude", text?, ms?}: the window should say goodbye and close itself. Sent by the host after an export event, or by Claude.
session.closing 0.4.0up (shell){reason}: the farewell started
session.stay 0.4.0up (shell){}: the human pressed Keep working; the window stays

Handoff. new in 0.4.0 A unique's manifest names which of its types are the export (the human pressed "Send to Claude"), the snapshot (reply to a state query) and the restore command. When an export event reaches the host it is saved as a session (~/.lets/sessions/<unique>/<id>.json), its data gains session: {id, handoff: true, saved: true, summary} as the first key, and the window receives session.close. Claude then answers in the transcript, not in the window. open {resume: "last" | id} later queues the restore command with the saved payload, so the window comes back as it was sent. Snapshot events update the saved session silently. ~/.lets/config.json "handoff": "stay" keeps windows open instead.

3.2 Entity (the companion)

Events (up):

Commands (down):

3.3 Chat

3.4 Components (anything the model invents at runtime)

Built-in component types every shell MUST provide: note, options, slider, toggle, form, image-drop, progress, code. Unknown types fall back to note with the raw props shown, so a future type never breaks an old shell.

3.5 Media

3.6 UI

4. Transport & security

LegMechanism
Window → HostPOST /lip/event (envelope or array). Header x-lets-token. 202 on accept.
Host → WindowGET /lip/stream?w=<windowId>: Server-Sent Events, data: = envelope JSON. Heartbeat comment every 15 s.
Window → Host (blobs)POST /lip/upload?w=&source=&component= raw body, content-type = mime. Returns {path,w?,h?,bytes,mime}.
Window → Host (runner info) 0.7.0GET /lip/run/info?w=<windowId>. Header x-lets-token. Returns whether the runner is enabled, whether this window's unique is allowed on this machine, the languages the manifest declares in permissions.exec, which of them this machine can run and with which sandbox, and the limits.
Window → Host (runner consent) 0.7.0POST /lip/run/consent?w=<windowId> body {"allow": true}. Records (or, with false, revokes) the human's consent for the window's unique on this machine. Returns {"allowed": true}.
Window → Host (run) 0.7.0POST /lip/run?w=<windowId> body {lang, code, stdin?, timeoutMs?, run?}. Streams the run as NDJSON (application/x-ndjson): one object per line with stream = system, stdout or stderr, then a last line with done:true, exit, ms, timedOut, killed, sandbox. Failures before the process starts are plain JSON with a 4xx status (consent, disabled, not-declared, unavailable, busy, bad-request). See docs/RUNNER.md.
Claude ↔ HostMCP (stdio). listen long-polls the up-queue; send enqueues down-commands.
Hooks → HostGET /lip/drain?cursor=<name> returns and advances a named cursor; used by the UserPromptSubmit hook so events reach Claude without a tool call.

Security. Loopback only; a per-host random token is required on every request (query t, header, or the window's URL fragment at open); CSP on served pages is default-src 'self'; uploads are size-capped (default 25 MB) and written only under the uploads dir; manifests are schema-validated before a unique is served. The runner (/lip/run) only runs after the human allowed that unique on that machine.

5. Queues & cursors

The host keeps one ring buffer of up-events (default 2000). Readers hold cursors: claude (the listen tool), hook (the prompt hook), plus any named cursor. listen returns events after its cursor and advances it; it blocks up to wait_ms when nothing is pending. Noise is coalesced before it reaches the buffer: pointer moves never enter it, and repeated graph.changed within 400 ms collapse to the latest.

6. Unique manifest (unique.json)

{
  "lip": 1,
  "name": "graph",
  "version": "0.1.0",
  "title": "Graph — talk in diagrams",
  "description": "Draw flows and ideas; the companion and Claude read them.",
  "entry": "ui/index.html",
  "guide": "GUIDE.md",
  "icon": "icon.svg",
  "domains": ["graph"],
  "capabilities": ["entity", "chat", "components", "media.image", "clipboard"],
  "events": { "graph.node.select": { "summary": "User selected nodes", "schema": "schemas/node.select.json" } },
  "commands": { "graph.node.add": { "summary": "Add a node", "schema": "schemas/node.add.json" } },
  "permissions": { "fs.write": ["uploads"], "net": [], "exec": [] },
  "session": { "export": "graph.export", "snapshot": "graph.snapshot", "restore": "graph.load" },
  "author": { "name": "…", "url": "…" },
  "license": "MIT",
  "repository": "https://github.com/…"
}

Rules the host enforces:

  1. name is ^[a-z][a-z0-9-]{1,31}$, unique across registries.
  2. entry resolves inside the unique folder; nothing outside it is ever served.
  3. domains may not include reserved ones.
  4. Every event/command type a unique declares starts with one of its domains.
  5. Unknown top-level keys are kept (forward compatibility) but ignored.
  6. lip major must match the host's.
  7. session (optional, 0.4.0) names the export / snapshot / restore types for handoff (§3.1b); each must start with one of the unique's domains. Without it the host assumes <domain>.export, <domain>.snapshot, <domain>.load.
  8. permissions.exec (optional, 0.7.0) lists the languages the unique may ask the host to run, for example ["python", "javascript"]. The host refuses /lip/run for any other language, and for a unique without exec.

7. Guide (GUIDE.md)

Prose for Claude: what each event means and how to react, what commands exist, and the unique's etiquette. The open tool returns it; the lets skill tells Claude to obey it for the window's lifetime. Keep guides under about 1,500 words.

8. Versioning

9. Future mediums

A unique for a medium without a pointer (voice, gesture, AR, neural) still emits entity.summon, component.action, media.*: the meaning of an act, not its mechanics. New domains are free to add; shells for new mediums implement §3 semantics in whatever way the medium affords. The envelope never changes shape.

Source of truth: protocol/LIP.md and the JSON schemas next to it in the lets repo.