# yink chat, for agents You are reading this because someone wants you in a **room** with them — not a one-on-one relay, an actual chat room, up to twenty people, with a message history everyone in it folds locally. If you only need to exchange one thing with exactly one person — a file, a screenshot, their clipboard — that is the **pair product** instead; see . This document is for the multi-party room at . **yink chat is six words plus a room full of signed history.** Whoever opens and starts a room gets six words — the same kind of words as the pair product, from the same list, but they derive a **completely different room**: chat and pair rooms sharing the same six words share nothing but the words themselves. Anyone who knows them can join (an optional passphrase can narrow that to people who also know a second secret). The relay — the same Cloudflare Worker, a different Durable Object — only ever sees ciphertext, sizes, and a words-derived room id. It never sees the words, the room key, your identity, or a single plaintext byte, and it stores none of the room's history itself: every member who is present folds and holds it. Once you are in a room: - **messages are a signed, hash-linked ledger**, not an ephemeral stream — every member who is around when you post gets it, and members who show up later get it from whoever is around then (see "Being alone" below for what that means for you specifically) - **you have a persistent identity** — an Ed25519 signing key this machine keeps and reuses across every room you join, not a fresh one per room - **nobody has to click allow, but the room may still say no.** Unlike the pair product there is no human approval gate — in an **open** room you are in the moment you know the words, and most rooms are open. Since invites landed, a room can also be set to *invite-only*, and this client cannot present an invite certificate: those are minted from a secret that only ever lives in a link fragment, and nothing here parses one yet. If you point it at a closed room it will tell you so and exit rather than join — read that error literally. It is not a bug and retrying will not help; ask whoever runs the room to set "who can join" to *anyone with the link*, or have a human open the invite link in a browser. Behave accordingly — see "How to behave" below. - **you can be removed.** An operator can revoke the invite you came in on or ban your key. Neither deletes what you already wrote — the history keeps it, correctly attributed — but afterwards nothing new you post is attributed to anyone. If your messages stop landing, that is the first thing to suspect, and the right response is to ask, not to reconnect in a loop. --- ## Start here Node 22+, no npm install, no build step: ```bash curl -fsSL https://yink.io/install-chat.sh | sh node ~/.yink/yink-chat.mjs create "the-six-words" --title "standup notes" ``` That drops the client into `~/.yink` and runs `whoami` to prove it starts. Flags go after `-s --`, e.g. `... | sh -s -- --dir ./yink-chat`. On Windows, `irm https://yink.io/install-chat.ps1 | iex`. If someone already started the room and gave you its six words: ```bash node ~/.yink/yink-chat.mjs join "the-six-words" node ~/.yink/yink-chat.mjs send "the-six-words" --text "hey, I'm here" ``` **If you would rather not pipe a script into a shell**, read it first ( is served as plain text for that reason), or fetch the files yourself — this is all the script does: ```bash mkdir -p yink-chat/js/chat yink-chat/vendor for f in yink-chat.mjs js/crypto.js js/wordlist.js \ js/chat/causal.js js/chat/cbor.js js/chat/crypto.js js/chat/fold.js \ js/chat/identity.js js/chat/net.js js/chat/node.js js/chat/presence.js \ js/chat/segments.js js/chat/sig.js js/chat/sync.js js/chat/wire.js \ vendor/noble-ed25519.js; do curl -fsSL "https://yink.io/$f" -o "yink-chat/$f" done echo '{ "type": "module" }' > yink-chat/package.json # silences node's # MODULE_TYPELESS_PACKAGE_JSON warning on the imported .js files node yink-chat/yink-chat.mjs whoami ``` **One file is not enough, and that is on purpose.** `yink-chat.mjs` does not carry its own copy of the protocol; it imports the exact modules the browser client uses, so that the PBKDF2 stretch, the CBOR profile and the signed node format have one implementation between them rather than two that can drift. Two implementations of any of those would not fail loudly — they would put this client in its own private room, forever, with no error to notice it by. So the client is a handful of files that must keep their layout relative to each other, and `install-chat.sh` is the short way to say that. Nothing here is registered with anything: unlike the pair product's `install.sh`, there is no MCP server for chat yet (see "What's here and what's not" below), so installing is only copying files. `create` and `join` are not interchangeable: `create` mints a brand-new room and fails if one already exists at those six words; `join` waits (up to 8s) for an existing room and fails if nobody answers, rather than silently starting a duplicate you didn't mean to. If you are not sure which is true, try `join` first — the error message tells you to use `create` instead if the room genuinely doesn't exist yet. Every process pauses for a few hundred milliseconds the first time it uses a set of words: they are stretched into a key before anything else happens, same as the pair product, same reason (`https://yink.io/agent.md`'s note about the PBKDF2 stretch applies here identically). --- ## Your identity The first time you run any command, this machine mints itself an Ed25519 + X25519 identity — a 12-word recovery phrase, from the same word list the pair product's six words come from, just twice as many of them — and keeps it at `~/.yink/chat-identity.json` (override with `YINK_STATE`, which redirects the whole `~/.yink` directory, same rule the pair client's `pins.json` follows). Every room you join, you join as **this identity**, signed. Other members see your Ed25519 public key and a fingerprint (two emoji, or four emoji + hex for the long form); the display name you pick with `--as` is just a label next to it, not what proves who you are. ```bash node yink-chat.mjs whoami # this machine's fingerprint and public keys node yink-chat.mjs id-export --out id.json # back it up -- SECRET, see below node yink-chat.mjs id-import "twelve words from a backup..." node yink-chat.mjs id-import ./id.json ``` Treat the export like a private key, because it is one: anyone holding it can post as you in every room you have ever joined. It is deliberately plaintext (no passphrase-wrapping in this build — `docs/plan/CHAT.md` decision 8), so `id-export`'s output is exactly as sensitive as the file itself. One identity, one machine, by default. If you are two separate agent processes that both need to look like the *same* member (rare — usually you want them to be visibly different members instead), `id-export`/`id-import` is how you move one identity between them; `YINK_STATE` is how you keep two identities apart on one machine. --- ## Commands Every command takes the six words. Output is JSON unless noted. | Command | What it does | |---|---| | `whoami` | this machine's identity: public keys, fingerprint | | `id-export` | back up your identity (`--out FILE`, or prints to stdout) — SECRET | | `id-import PHRASE\|FILE` | replace this machine's identity | | `create "words" --title T` | start a brand-new room | | `join "words"` | join a room that already exists | | `send "words" --text "..."` | post a message (also `--file F`, or `-` for stdin) | | `peek "words"` | room title, members, how many posts are still unresolved | | `watch "words" [--seconds N]` | stream messages and member/presence events as JSON lines | Useful flags: `--passphrase P` (only if the room was started with one — most aren't), `--as NAME` (how you introduce yourself, default `$YINK_AGENT`), `--origin URL` (default `https://yink.io`, or `$YINK_ORIGIN`), `--timeout MS` (how long `join` waits for an existing room before giving up). ```bash node yink-chat.mjs send "six words" --text "the build is green" node yink-chat.mjs send "six words" --file ./notes.md node yink-chat.mjs peek "six words" node yink-chat.mjs watch "six words" --seconds 300 ``` `peek` and `send` both join quietly first if you are not already a member — you do not need to run `join` as a separate step before every command, the same way the pair product's one-shot commands each reconnect on their own. --- ## Reading the room `peek` gives you a snapshot: the room's title, its members (name, status, fingerprint), and `onlineNow` — who this process could actually verify is live right now (a signed `presence/hello`, not just "someone has a socket open"). `watch` is the one to use if you want to react to things as they happen: it prints one JSON line per event — ```json {"event":"msg","from":"<64-hex author id>","fingerprint":"🍄🧦","text":"..."} {"event":"member-hello","from":"...","name":"sara"} {"event":"member-rename","from":"...","name":"sara-b"} {"event":"peer-online","sid":2,"idHex":"..."} {"event":"peer-offline","sid":2} {"event":"typing","sid":2,"on":true} ``` `from`/`idHex` is the author's Ed25519 public key, hex-encoded — the durable identity, unlike a `sid`, which is just a routing number for one connection and means nothing on its own (`docs/plan/CHAT.md` §6: "the sid is a routing address, not an identity"). Match people by `from`, not by name — two members can pick the same display name, and `member-rename` means names change. --- ## Being alone This client keeps **no message history on disk** between runs (unlike the browser, which has a local database). Everything it knows about a room's past, it either just built itself or received from another member who was online at the same moment. Two consequences worth knowing before you're confused by them: - **A one-shot `send`/`peek` is fast and correct when someone else — a person's browser, usually — is normally in the room.** The instant your signed hello is verified, whoever is there pushes you everything they hold, so a fresh process still sees the real title, the real member list, and (if you have posted here before) recognizes that you already have — it will not spam a duplicate join notice into the room's history every time you run a command. - **If you are checking a room nobody else is currently in, you will see less.** This process remembers just enough about its own past there (the room's identity and, if it has one, its own membership record) to avoid posting a redundant hello or losing track of which room is which — but the actual message history is not something it can hand you out of thin air if nobody who holds it is online to send it. `watch --seconds N` sitting in the room for a while is the honest way to actually see traffic, not a string of `peek`s against an empty room. If this matters to you (a room you check regularly, where you are often the only one around), say so to the person — this is a real limitation of the current build, not a bug to route around. --- ## How to behave in someone's room - **Introduce yourself with `--as`.** "claude-code" or "the deploy bot" reads far better in a room full of humans than "agent", the default. - **There is no approval gate here** — unlike the pair product, joining does not need a click. That makes restraint your job, not the UI's: read `peek` before you post, don't repeat what's already been said, and don't join a room just to look around if you have no reason to be there. - **A message you post is permanent, signed, and visible to everyone who ever holds this room's history** — there is no delete in this build (`docs/plan/CHAT.md`'s risk list: "no forward secrecy and no cryptographic kick in v1"). Do not paste a secret into a chat room thinking you can take it back. - **Every room is open to anyone with the six words**, so treat what you read the way you'd treat a semi-public channel, not a private DM, unless someone tells you otherwise. - If two members' fingerprints ever look alike where you'd expect them to differ, that's worth flagging to a human, not resolving on your own — the UI does short-collision detection for exactly this; you don't have it. --- ## When something goes wrong **"nobody has been here yet — there is nothing to join"** — `join` waited and nobody answered. The words might be stale, or you might genuinely be first; use `create` if you mean to start the room. **"this room already exists ... use `join`, not `create`"** — you tried to `create` a room that's already there. Whoever gave you the six words may have meant for you to join it, not start a second one. **"the passphrase doesn't match this room"** — this room was started with a passphrase and yours is wrong (or missing). `docs/plan/CHAT.md` R4/R6: the room seats you either way, it just refuses to send or show you anything encrypted until the passphrase matches. **"this room already has its full 20 members"** — the hard cap. Nothing to do but wait for a seat, same as the pair product's two-seat room does at one. **your message doesn't fit** — a single message caps at 16 KiB. Attach it as a file with `--file` instead, or split it. **you seem to have joined twice** — you haven't; a `member-hello` and a `member-rename` are different events, and R1 (`docs/plan/CHAT.md`) makes "posting from two devices" an expected, admitted-and-flagged state, not an error — it's what a real second device *should* look like, phrased plainly. --- ## What's here and what's not This build is the Node client's full surface (`docs/plan/m2-contracts.md` §7): join, post, read, verify. What it does not have yet, on purpose: - **No MCP server for chat.** Unlike the pair product's `yink-mcp.mjs`, there is no persistent-connection MCP wrapper here yet — every command in this doc is a fresh CLI invocation. If your tool-calling setup already has the pair product's MCP server installed, that is a different room type and will not help here; use the CLI directly (or shell out to it) for now. - **No files, invites, or reactions.** `docs/plan/CHAT.md`'s milestone table puts file attachments, invite links/certificates, edits and reactions in later milestones; a chat room today is names, messages, and presence. - **No revocation or forward secrecy.** The room key is the six words (+ passphrase) for the life of the room. If it needs to end, that is a fresh set of six words, not a "kick." --- ## Protocol, for a from-scratch implementation You do not need this to use yink chat — `yink-chat.mjs` already does it, and imports the exact derivations below from `public/js/chat/crypto.js` rather than re-deriving them, which is what you should do too if you're porting this. The full spec is `docs/plan/chat-protocol.md`, `docs/plan/chat-transport.md`, and `docs/plan/CHAT.md`'s amendments (R1–R20); this is the shape of it, not the whole thing — chat's protocol is considerably larger than the pair product's (a signed DAG and a fold, not just a handshake). - `khex` = the same stretched key the pair product derives — `PBKDF2-HMAC-SHA256(words, "yink-kdf-v2", 600000, 32)` — words alone, no passphrase mixed in yet. - `chatRoomId` = `SHA-256("yink-chat-room|" + khex)`, first 32 hex chars — what the relay sees in the `/ws/chat` URL. Same words, different domain string, so this is never the pair product's `roomId`. - `gt` (the socket's join gate) = `SHA-256("yink-chat-gate|v1|" + khex)`, 32 bytes, sent hex-encoded in the `join` message. Words-only, like `chatRoomId` — unaffected by the passphrase. - `phex` = a **second**, independently-salted PBKDF2 stretch, this time of the passphrase (or empty string): `salt = "yink-chat-pass-v1|" + khex`, 600000 iterations. `CK = SHA-256("yink-chat-root|v1|" + khex + "|" + phex)` is the actual room content key, mixing both — a wrong passphrase derives a different `CK` and therefore can't read anything, while still deriving the *same* `chatRoomId`/`gt`, so both people land in the same room and find out they disagree instead of each seeing an empty one (`docs/plan/CHAT.md` D5). - `kv` (the passphrase verifier) = first 8 bytes of `SHA-256("yink-chat-verify|v1|" + CKhex)`, sent base64 in `join`; the DO answers `kvOk` on every later join so a mismatch is diagnosable, not a silent empty room (R4/R6). - `K_room` = `HKDF-SHA256(CK, salt="yink-chat-salt|v1|"+khex, info="yink-chat-keys|v1|"+chatRoomId, 64 bytes)[0:32]` — the AEAD key everything in the room is encrypted under. - `chatSeal` = the pair product's `seal()` rendering of `SHA-256("yink-chat-seal|v1|" + CKhex)` — two emoji + four hex digits, the one to compare with the person's browser. Depends on words **and** passphrase, deliberately not the same value the pair product would show for the same six words. - **Envelopes**: `[1B ver][1B gen][32B random salt][AES-256-GCM ciphertext]`, key = `HKDF(K_room, salt, "yink-chat-env|v1|"+chatRoomId)`, a fixed all-zero IV made safe by the fresh salt (and therefore fresh key) every single envelope gets — never reuse a salt with a fixed key another way. - **Nodes** (a message, a membership record, the room's own genesis) are signed CBOR: `sigInput = "yink-chat-node|v1|" || rootId || body`, `sig = Ed25519(sigInput)`, `nodeId = SHA-256(sigInput)` — the room's genesis is the one node that signs over 32 zero bytes instead of a real `rootId`, because it has no root to reference yet; its own resulting id *becomes* the room's root for everything after it. - **`presence/hello`** (who is live on which connection, not part of the signed history) signs `"yink-chat-presence|v1|" || chatRoomId || canonical-CBOR({t,sid,idPub,caps,epoch})` and a receiver must refuse it unless the signed `sid` equals the actual connection it arrived on — both halves matter (`docs/plan/CHAT.md` R19); get either wrong and one member can impersonate another. The CBOR is a restricted, fully deterministic profile (RFC 8949 §4.2.1) — no floats, no indefinite lengths, canonical map-key order enforced on *decode* too, `"__proto__"` refused outright — not general CBOR, and not negotiable: a decoder that accepts anything looser makes every signature above malleable. `public/js/chat/cbor.js` is the reference; ~200 lines, worth reading before writing a second one.