Interface kit
Let your app notice how people use it: who is looking, what they say, how they react. Inside an Inverse room, that context goes to the room with each person's name on it, and on to your agent.
Start
The kit uses native browser APIs and has no dependencies. Start it from a user gesture, because it asks for the camera and microphone.
import { createInterfaceKit } from "inverse-embed/interface-kit";
button.onclick = async () => {
const kit = await createInterfaceKit({ preset: "listen", room: true }).start();
kit.on("voice", (e) => console.log(e.data)); // your own speech, interim and final
kit.on("transcript", (line) => console.log(line.by, line.text)); // everyone in the room
kit.on("reaction", (e) => console.log(e.data.emoji));
};kit.stop() releases the camera and microphone. Outside an Inverse frame the kit still works; your callbacks get every event and nothing is sent anywhere.
Presets
| Preset | Turns on |
|---|---|
listen | Voice and attention. |
see | Faces and attention. |
all | Both. The default when you pass no perception options. |
Faces turn on only where the browser has FaceDetector, so people aren't asked for a camera that can't be used. Voice uses the browser's speech recognition; where it's missing you get one { supported: false } event.
Options
const kit = createInterfaceKit({
preset: "all",
video: document.querySelector("video"), // or true
faces: true, // native FaceDetector, about twice a second
attention: true, // { visible, focused, looking }
voice: { lang: "en-US" },
commands: { "next slide": () => next(), "go back": () => back() },
room: true, // receive everyone's attributed lines
pens: true, // turn on the room's markup tools (off by default)
share: true, // send derived context to the room (default)
onContext: (event) => console.log(event.kind, event.data),
});Events
Every event is { kind, data, at }. Listen with kit.on(kind, fn), which returns an unsubscribe function, or with onContext for all of them.
| Kind | data |
|---|---|
presence | { present } |
faces | { count, boxes }, or { supported: false } |
attention | { visible, focused, looking } |
voice | { text, final }, or { supported: false } |
command | { name, text } when a final transcript contains a phrase from commands |
reaction | { emoji?, expression?, intensity? } |
custom | Anything you send with kit.emit("custom", data) |
Reactions
Call kit.react("๐ฅ"), or let voice cues react for you: โhahaโ ๐, โwowโ ๐ฎ, โniceโ ๐, โughโ ๐ฉ. Browsers have no facial expression API, so faces don't trigger reactions yet.
Attribution
Inverse stamps every event that reaches the room with by: the name of the room member it came from. The host sets it, not your app, so an app can't speak for someone else.
Room lines
With room: true, while the kit is started, on("room") receives everyone's lines and on("transcript") only the spoken ones. Nothing flows until the participant allows it: Inverse asks once per app, inline.
{ kind: "voice" | "reaction", by: "Ana", text: "can we zoom in?", at: 1793491200000 }Markup tools
An Inverse room is just your app and a bottom bar. The markup tools (pen, highlighter, arrow, box, eraser) are off unless you turn them on, either with your app's setting (inverse share --pens or the toggle in /dev) or from the kit with pens: true. The kit sends it when Inverse says hello, before start(). When they're on, a pen button in the bottom bar opens the tools, and everyone's marks show over the app.
Streams
kit.stream(name, { mirror }) is a shared pool of small JSON values: votes, picks, ratings. Inverse stamps each push with who sent it and keeps the latest 500 in the room. Names match [a-z0-9-]{1,40}; values are up to 4 KB, at most 20 pushes a second.
const votes = kit.stream("vote", { mirror: "latest" });
votes.on((entries) => render(entries)); // [{ name, by, value, at }]
button.onclick = () => votes.push({ pick: 2 });
votes.values(); // the current mirror| mirror | What comes back into each frame |
|---|---|
| all | Every entry (latest 500) |
| latest | The latest value per person |
| none | Nothing: the stream is for agents only |
Values mirror back while the kit is started, and only after the participant allows room data (the room grant). Agents read streams in the intent package by alias, and through the MCP tool get_stream.
Cursors
kit.cursors({ target, render }) shares the pointer, at most 20 times a second, normalized to target (or the viewport). Each update carries an intent and over: the closest element's aria-label, #id or tag. Positions travel through the room's presence and are never stored. It needs the participant's cursors grant.
const cursors = kit.cursors({ target: document.querySelector("main"), render: true });
cursors.on((peers) => console.log(peers)); // [{ id, name, color, x, y, intent, over }]
cursors.intent("drag", "Card: Launch plan");
cursors.stop(); // stops sharing, removes the drawn cursorsrender: true draws a small cursor and name pill in each person's colour inside target; it ignores the pointer and skips animation under reduced motion. Agents never get positions: a pause of 1.5 seconds or more on one element becomes an attention event { intent, over, dwellMs }.
What leaves the browser
Your callbacks see everything. The room gets only derived context:
- Never video, audio or images.
- Final transcripts only; interim text stays in the app.
- Face counts without the boxes.
- At most 4 events a second per kind, each up to 4 KB.
- The room keeps the latest 500 context events.
- Nothing at all with
share: false.
inverse sync exports the context (context in session.json and events.jsonl, counts in recap.md), and it feeds the intent package. Apps hosted on Inverse can ask for the camera and microphone; the browser still asks each person first.
Without the package
The kit sends one message type to the Inverse frame. You can send it yourself from any framework.
window.parent.postMessage(
{ type: "inverse.interface.context", event: { kind: "custom", data: { step: "checkout" }, at: Date.now() } },
hostOrigin,
);Send { type: "inverse.interface.hello" } to the parent to learn hostOrigin: the host answers with inverse.hostReady from its origin.