Skip to content
Rigel docs
Open Rigel

The household socket

One WebSocket per household tells a client when to pull, shows who else is online and where, carries conversations with Rigel, and lets several people edit a document at once.

Connecting#

Open a WebSocket to GET /api/v1/households/{household_id}/socket, with the same rigel_session cookie as every other request. Only members of the household can connect. A request with no session is 401 unauthenticated, a household you aren't in is 404 not_found, and a request that isn't a WebSocket upgrade is 400 bad_request. The route is not in the OpenAPI document, because its messages are not request and response bodies.

TypeScript
import WebSocket from "ws";

const householdId = "hh_01JC8Q4W2N7XK5R3VYTZ9D6HBE";
const socket = new WebSocket(`wss://rigel.house/api/v1/households/${householdId}/socket`, {
  headers: { Cookie: `rigel_session=${process.env.RIGEL_SESSION}` },
});

socket.on("message", async (data) => {
  const text = data.toString();
  if (text === "pong") return;
  const message = JSON.parse(text);
  if (message.type === "welcome" || message.type === "poke") await pull(); // from the sync guide
});

The household answers every new connection with welcome. A browser tab on rigel.house sends the cookie by itself, and that is how the web app connects. A script needs a client that lets it set the Cookie header, such as the ws package.

Messages are JSON text frames with a type. A frame that doesn't parse, or is longer than its kind allows, is dropped without an answer: 2,048 characters for presence, 256,000 for an agent.* message and 1,048,576 for a text.* message. Presence has no acknowledgements, sequence numbers or retries: the latest message wins, and a lost one doesn't matter. Sending the text ping gets pong back, which is not JSON.

Messages#

TypeFromMeaning
welcomethe householdSent once on connect: protocol_version, which is 1, and version, the household's version now.
pokethe householdThe household is now at version. If you are behind, pull.
presenceyouYour tab's complete latest state: where it is, what it is about, where the pointer is.
presence_statethe householdOther people's tabs whose state changed, with now, the household's clock.
byethe householdA tab closed or failed: its user_id and tab_id.
agent.*bothA conversation with Rigel. See the agent guide.
text.*bothEditing a document together. See below.
JSON
{ "type": "welcome", "protocol_version": 1, "version": 44 }

Ignore message types you don't recognize. The set grows when new features arrive, and a client that skips what it doesn't know keeps working.

Pokes: when to pull#

After every commit, and once after a push that applied anything, the household sends every connected socket:

JSON
{ "type": "poke", "version": 45 }

A poke carries no rows. It says the household is at that version. If your copy is behind it, pull from your version, as the sync guide describes. One poke can stand for several commands, and the pull catches up on all of them. The same goes for welcome, so pull when you connect.

Presence: who is online and where#

Presence makes a household feel shared: when someone else is looking at the same thing, you see them there. It lives in the household's memory only and is never stored. A tab reports its own state, and the household tells everyone else.

A tab sends its complete state every time it changes. Send one when you connect, and again whenever the page, the pointer or the sharing choice changes.

JSON
{
  "type": "presence",
  "tab_id": "script-a1b2c3d4",
  "route": "/family",
  "params": {},
  "title": "About our family",
  "focus": null,
  "cursor": { "region": "family.everyone", "x": 0.42, "y": 0.18 },
  "sharing": true,
  "active_at": 1794240000000
}
FieldMeaning
tab_idYour tab's own ID, made when it starts: 8 to 64 characters of A-Za-z0-9_-. It is unique among one person's connections, not globally.
route, paramsThe page, as the web app's route template and its parameters: /family, or /recipes/$recipe_id with { "recipe_id": "rcp_01JC…" }. / is the home screen. At most 4 parameters, and never a search string.
titleThe page's title in words, up to 80 characters, such as "Tuesday's dinner". null when the page has none, and other tabs fall back to the route's name.
focusThe ID of the record the page is about, or null.
cursor{ region, x, y }: a pointer over a named page region, with x and y as fractions from 0 to 1 across and down that region. null when it isn't over one.
sharingWhether this person shares their view.
active_atYour clock when the person last used the tab, in UTC milliseconds. The household uses it only to see that it changed, and stamps its own time, so a wrong device clock does no harm.

Invites aren't built yet, so a household has one signed-in member today and nobody else shows up. The messages below are what you receive once a second member joins.

The household answers with presence_state. The first one a tab receives after it reports lists every other tab. After that it carries only the tabs that changed, each complete: replace your copy of a tab by its user_id and tab_id. A bye removes one.

JSON
{
  "type": "presence_state",
  "now": 1794240000500,
  "tabs": [
    {
      "user_id": "usr_01JC8Q4W3A5M0F2G7SNPXQ1RKD",
      "tab_id": "k3m9x2qa7b",
      "active_at": 1794239998000,
      "sharing": true,
      "route": "/family",
      "params": {},
      "title": "About our family",
      "focus": null,
      "cursor": { "region": "family.everyone", "x": 0.31, "y": 0.62 }
    }
  ]
}

Names aren't in the message. A tab is identified by user_id, which matches the user_id of a family person row, so look the name up there. Compare now with active_at to tell how long a tab has been idle.

What each viewer sees#

  • You never see your own person's other tabs. "Everyone else" means other people.
  • A tab that is not sharing arrives as only { user_id, tab_id, active_at, sharing: false }. It is online and nothing more. The household keeps no title, focus or pointer for it and never sends its route.
  • A sharing tab shows its route, parameters, title and focus to everyone.
  • Its pointer goes only to tabs on the same route, the same template with the same parameters. A pointer that hasn't moved for 10 seconds isn't included in a list sent to someone who arrives later.
  • Turning sharing off hides you. It doesn't blind you: you still see who is online and, when someone sharing is on your page, their pointer.

Following. Following isn't a message. It is what a client does with a sharing tab's route and params: take the person there each time they change, and stop when the person navigates somewhere else, when the tab you follow stops sharing, or when it says bye. A route your client doesn't have isn't followed to.

Pointers. Screens differ in size, so a pointer is a position inside a named region of the page, as a fraction across and down it. The web app names regions in the page, for example meal-plan.day.tuesday. A page with no regions shows faces and no pointers. The web app hides a pointer that hasn't moved for 8 seconds until it moves again.

Limits. A report whose only change is the pointer is passed on at most once every 40 milliseconds per tab. One that comes sooner is dropped whole, so the next one that differs still gets through. Reports that change the route, focus or sharing are never limited. A tab that is still in use is announced again at most every 15 seconds, so idle tabs can fade. The web app calls a tab idle after 60 seconds.

Editing a document together#

Text that several people type into at once, and Rigel too, can't travel as commands. One command per keystroke would flood the history, and two people typing would overwrite each other. Such a text is a Yjs document, and its updates travel over this socket. Markdown documents are edited this way. The text's ID is the document's, doc_…, and the text is the Yjs Y.Text named content. An HTML document or an uploaded file can't be edited live: joining one answers text.closed with the reason not_editable. Any member of the household can join any document that can be edited.

The rule that ties this to the rest of the API is that the stored text is what commands see, and the Yjs state is what editors see.

  • The household writes the live text into the document 3 seconds after the last edit, at most 30 seconds after the first unsettled edit however steadily people type, and when the last editor leaves. The history records it as one change by the person who edited first, so it says who edited, not one entry per keystroke. documents.document.read shows the stored text, so it can trail what editors see by a few seconds.
  • Before any documents.* command runs, the household settles the live text first. So documents.document.edit, documents.document.write and the others always start from the text people see.
  • After a command changes the text, the household applies the change to the live text as small edits, so open editors see it where it happened and keep their cursors. If someone typed while the command ran, their typing is kept, and where the two touch the same place the command's result wins. While Rigel is writing, its awareness state shows a name of Rigel and writing: true for 4 seconds.

You need a Yjs library. A client joins, applies what the household sends, sends its own edits and leaves.

MessageFromFields and meaning
text.joinyoutext_id, state_vector and lineage. Opens the text. state_vector is the Yjs state vector of your copy, base64, and empty for none. lineage is the ID the household gave the state your copy came from, or null.
text.syncthe householdThe answer to a join: lineage, update (what you are missing) and state_vector (the household's). Apply the update, then send one text.update with Y.encodeStateAsUpdate(doc, state_vector) unless it is empty. That carries edits you made while away.
text.updatebothtext_id and update, a Yjs update in base64. Yours go to the household and on to everyone else. Another editor's, or the household's own after a command, come back to you. You never get your own back.
text.awarenessbothtext_id and update, a y-protocols awareness update in base64: who is in the text and where their cursor is. Optional. An editor puts { user: { name, kind } } in its awareness state, with kind of person or agent. The household relays it as sent.
text.leaveyoutext_id. Closing the socket leaves every text you had open.
text.rejointhe householdtext_id. The household has no room for you, because it restarted or your update depended on edits it lacks. Join again with your state vector. Nothing is duplicated or lost.
text.closedthe householdtext_id, reason and a message. The text can't be edited now. reason is deleted, not_editable (it was converted to HTML) or error.
text.errorthe householdtext_id, code and a message. code is too_large, bad_update or unavailable.
TypeScript
import WebSocket from "ws";
import * as Y from "yjs";

const householdId = "hh_01JC8Q4W2N7XK5R3VYTZ9D6HBE";
const documentId = "doc_01JCB2N4K8R5TW7XQ3ZV6D9MHE"; // a Markdown document
const toBase64 = (bytes: Uint8Array) => Buffer.from(bytes).toString("base64");
const fromBase64 = (text: string) => new Uint8Array(Buffer.from(text, "base64"));

const socket = new WebSocket(`wss://rigel.house/api/v1/households/${householdId}/socket`, {
  headers: { Cookie: `rigel_session=${process.env.RIGEL_SESSION}` },
});
const send = (message: object) => socket.send(JSON.stringify(message));

const doc = new Y.Doc();
const text = doc.getText("content"); // the document's Markdown

// Edits made here go to the household. Updates that came from it are marked, and not sent back.
doc.on("update", (update: Uint8Array, origin: unknown) => {
  if (origin !== "household") send({ type: "text.update", text_id: documentId, update: toBase64(update) });
});

function join() {
  send({ type: "text.join", text_id: documentId, state_vector: toBase64(Y.encodeStateVector(doc)), lineage: null });
}
socket.on("open", join);

socket.on("message", (data) => {
  const raw = data.toString();
  if (raw === "pong") return;
  const message = JSON.parse(raw);
  if (message.text_id !== documentId) return; // welcome, pokes and presence are other messages
  if (message.type === "text.sync") {
    Y.applyUpdate(doc, fromBase64(message.update), "household");
    const missing = Y.encodeStateAsUpdate(doc, fromBase64(message.state_vector));
    if (missing.length > 2) send({ type: "text.update", text_id: documentId, update: toBase64(missing) });
    text.insert(text.length, "\n- Pack the cooler.\n"); // goes out as a text.update
    setTimeout(() => {
      send({ type: "text.leave", text_id: documentId });
      socket.close();
    }, 1000);
  }
  if (message.type === "text.update") Y.applyUpdate(doc, fromBase64(message.update), "household");
  if (message.type === "text.rejoin") join();
  if (message.type === "text.closed" || message.type === "text.error") console.error(message.message);
});

You don't need the socket to change a document's text. Read it with documents.document.read, which returns the text in pieces (from_char and max_chars, with total_chars saying how long it is) and a content_version. Then documents.document.edit makes small changes that merge with people typing, and documents.document.write replaces the whole text. Pass the content_version you read as base_version, and a write refuses with document_changed and the current version instead of overwriting what changed since.

Keep a copy between visits if you want to edit offline. Save the document's Yjs state with the lineage from the last text.sync. When you reconnect, join with that state vector and that lineage, and the edits you made while away go up in the handshake. A lineage the household doesn't recognize means the text started over, which happens only when it was converted to HTML and back while you were away. The household then sends the whole text, and you start from a new document. Offline edits in the old copy are lost. A client with no lineage never mismatches.

Limits. An update can be at most 512 KiB before base64, and a text at most 2,000,000 characters. An update that is too large, or would push the text past its limit, is refused with a text.error of too_large and the text is unchanged. Shorten the text, then join again to send the difference as one update. A message that doesn't match the schema is dropped without an answer.

JSON Schemas#

The shapes of the sync and presence messages are published, for clients written in other languages. The file is public and describes shapes only.

Shell
curl https://rigel.house/api/v1/socket-schema.json

It returns client_message, a JSON Schema for what you send, and server_message, one for what the household sends. Both are JSON Schema draft 2020-12. They cover welcome, poke, presence, presence_state and bye. The agent.* and text.* messages are not in the file yet. The agent guide lists the agent.* messages, and the section above on editing together lists the text.* ones.

Reconnecting#

A socket drops: the network changes, the household restarts, or a deploy rolls out. Reconnect with a growing wait. The web app waits 500 milliseconds, doubles the wait after each failure up to 15 seconds, and goes back to 500 milliseconds once a welcome arrives. It also tries again at once when the browser comes back online.

  • Pull after you reconnect. Pokes sent while you were away are gone. welcome carries the current version, so the pull it triggers catches you up.
  • Report presence again. Send your full state with the same tab_id. The household treats it as the same tab, and the old socket stops speaking for it.
  • Forget the tabs you knew. Until the household sends the list again, you know nothing about who is online.

The household puts idle sockets to sleep, so keeping one open as long as you like is fine.

Rigel is in early access, and sign-up is by invitation. Open Rigel