# yink for agents You are reading this because someone wants to share things with you — text, files, images, their screen, a terminal — from a browser tab, without emailing you anything or pasting base64 into the chat. **yink is a room with exactly two seats.** The person opens , which shows six words. Those six words are the encryption key. You take the second seat by knowing them. The server relays ciphertext and never sees the words, the keys, or anything you exchange — it can refuse you, stall you, or drop the room, but it cannot read what crosses it, alter it, or replay it back. Once you are in the room: - the **shared text** is readable and writable by both of you, always - **files** the person sends arrive automatically - **anything else** — a screenshot, their screen, their clipboard, a file off their disk, the shared terminal — you must **ask** for, and they see the request in the browser and click allow or deny --- ## Start here Use whichever runtime the machine already has. The two clients are the same program in two languages — same commands, same flags, same output, same room. ```bash # Node 22+ — nothing to install at all curl -fsSL https://yink.io/yink.mjs -o yink.mjs && node yink.mjs peek "the-six-words" # Python 3.8+ — needs `cryptography` (pip install cryptography) curl -fsSL https://yink.io/yink.py -o yink.py && python3 yink.py peek "the-six-words" ``` If you are not sure what is installed, let the shell decide: ```bash command -v node >/dev/null 2>&1 \ && { curl -fsSL https://yink.io/yink.mjs -o yink.mjs; node yink.mjs peek "the-six-words"; } \ || { curl -fsSL https://yink.io/yink.py -o yink.py; python3 yink.py peek "the-six-words"; } ``` Prefer Node when both exist — it needs no package at all. Everything below is written `node yink.mjs`; `python3 yink.py` takes exactly the same arguments. Every process pauses for a few hundred milliseconds the first time it uses a code: the words are stretched into a key before anything else happens. That is once per process, not once per command, so it is a reason to prefer the MCP server for a long session, not a reason to worry. `peek` prints who is on the other end, what is available, and **two seals**: - **room** — derived from the six words, identical for the whole life of the room. This is the one to read back: *"does your browser show 🌙🚲 66C5?"* - **link** — this connection's keys. It changes every time you reconnect, and a one-shot command reconnects on every call, so do not read this one back or you will be reciting a different code every turn. If the **room** seal does not match what their browser shows next to `room`, stop — you are not in the room you think you are. ### The first connection needs a human The very first time you join a room, the browser holds **everything** back — no text, no files, no inventory — and asks the person to confirm that the thing which just joined is theirs. Until they click, you will see: > connected, but the person has not accepted this session yet That is not an error to retry. Tell them to look at their browser and click *"yes, that's mine"*, then run your command again. ### The room key When they accept, both sides derive and keep a **256-bit key for that room**, and every later connection is authenticated with it. This matters: six words read aloud, or glimpsed over a shoulder, stop being enough to enter the room the moment it is paired. Your copy lives in `~/.yink/pins.json` (override with `YINK_PINS`; it follows `YINK_STATE` if you set that). Treat it as a secret — it decrypts the room. Delete it and you cannot rejoin; you need a fresh code. Both clients share the file, so Node and Python can take turns in the same room. --- ## Commands Every command takes the six words. Output is JSON unless noted. | Command | What it does | |---|---| | `peek` | who is there, what they are sharing, what you may ask for | | `get-text` | print the shared text | | `set-text --text "..."` | write the shared text (also `--file F`, or `-` for stdin) | | `ask --for WHAT` | ask for something they must approve — see below | | `send --file PATH` | put a file in their browser with a save button | | `pull --out DIR [--seconds N]` | save whatever they send while you wait | | `watch [--seconds N]` | stream room events as JSON lines | Useful flags: `--origin URL` (default `https://yink.io`), `--as NAME` (how you introduce yourself — use your actual tool name), `--json`, `--timeout MS`. ```bash node yink.mjs get-text "six words" node yink.mjs set-text "six words" --text "here is the fixed config" node yink.mjs send "six words" --file ./diagram.png node yink.mjs ask "six words" --for screenshot --note "to see the error dialog" --out err.png ``` --- ## Asking for things ```bash node yink.mjs ask "six words" --for WHAT --note "why you need it" [--out FILE] ``` | `--for` | You get | Approval | |---|---|---| | `screenshot` | one JPEG of a window or screen they pick | they click allow, then pick | | `screen` | a series of JPEG frames — `--every MS --count N` | they click allow, then pick | | `clipboard` | their clipboard as text | they click allow | | `file` | a file they choose in a picker | they click allow, then pick | | `terminal` | the text of a shared terminal, if one is open | they click allow | | `text` | the shared text | automatic, no prompt | To fetch something the room **already holds**, use its id from `peek` — any inventory entry with `"fetchable": true` can be asked for by id, with no picker: ```bash node yink.mjs peek "six words" # find the file's id node yink.mjs ask "six words" --for file --id 3 --note "the log you sent earlier" --out log.txt ``` That still prompts, because the file may have crossed before you were in the room. Files stop being fetchable once the tab has held enough of them to start forgetting the old ones — ask the person to send it again. **Always pass `--note`.** It is shown to the person verbatim, next to the allow button, and it is the only thing they have to judge the request by. "to see the error dialog you mentioned" gets approved; a bare request looks like something went wrong. A live screen is a flipbook, not video: frames arrive as stills on the interval you asked for, because that is what you can actually read. Ask for a small `--count` and ask again if you need more. If they deny, you get `ok:false` and a reason. That is an answer, not a failure — do not immediately re-ask. If nobody clicks, the request times out and is withdrawn from their screen. --- ## Receiving Files the person sends arrive without you asking. `pull` writes them to disk: ```bash node yink.mjs pull "six words" --out ./from-them --seconds 120 ``` `watch` prints an event per line (`text`, `file`, `inventory`, `peer-left`), which is the one to use if you want to react to things as they happen. --- ## As MCP tools instead If you would rather have tools than a CLI: ```bash curl -fsSL https://yink.io/install.sh | sh # macOS / Linux irm https://yink.io/install.ps1 | iex # Windows PowerShell ``` That fetches the two files into `~/.yink` and registers the server with whichever CLIs it finds (Claude Code, Codex). Or do it yourself: ```bash mkdir -p ~/.yink curl -fsSL https://yink.io/yink.mjs -o ~/.yink/yink.mjs curl -fsSL https://yink.io/yink-mcp.mjs -o ~/.yink/yink-mcp.mjs claude mcp add yink -s user -- node ~/.yink/yink-mcp.mjs # Claude Code codex mcp add yink -- node ~/.yink/yink-mcp.mjs # Codex ``` The server has to run **locally**. It is the peer — it holds the six words and does the key exchange — so there is no hosted URL to point at; a hosted one would put the keys on the relay. That also means ChatGPT cannot use yink: it has nowhere to run a process that holds a key. Tools: `yink_connect`, `yink_status`, `yink_inventory`, `yink_read_text`, `yink_write_text`, `yink_ask`, `yink_get_file`, `yink_send_file`, `yink_received`, `yink_disconnect`. Screenshots come back as image content, so you see the picture rather than a path. The MCP server holds one connection open for the whole session, so the person is not re-prompted for every call and their "for this session" approvals stick. Prefer it if you will be in the room for a while. Set `YINK_CODE` to join automatically, `YINK_ORIGIN` to point elsewhere, `YINK_SAVE_DIR` to choose where incoming files land. --- ## When something goes wrong **"room already has two peers"** — both seats are taken. Usually you connected before under a different state file, so the room thinks the old you is still sitting there. Ask the person to click **end session** and share a fresh code. **"nobody joined within Ns"** — the tab is closed, or the code is stale. Codes are single-use and rooms expire after 24 hours. Check the word order too: the six words are a sequence, and a reordered code derives a different room, so you sit alone in an empty one instead of getting an error. **"key confirmation failed"** — you and the browser do not have the same six words. Re-read them; do not retry with a guess. **"the browser has a key for this room but this machine does not"** — that room was paired from a different machine, or your `pins.json` was deleted. There is no way back in: ask for a fresh code. The browser is refusing on purpose, because accepting would let anyone who merely read the six words walk in. **"the browser does not hold this room's key"** — the person reopened the tab, so their side lost the key while you kept yours. They can choose *"start over with the words"* in the browser, which re-pairs and re-keys. **yink.py exits saying it needs `cryptography`** — `pip install cryptography`, or switch to the Node client, which needs nothing. Python's standard library has no AES-GCM and no ECDH, so there is no third option. **The seal changed since last turn** — expected, if it was the *link* seal. Each command is a new process doing a fresh key exchange. Compare the **room** seal, which does not move. Using the MCP server instead holds one connection for the whole session, so even the link seal stays put. **Your one-shot commands each reconnect.** The resume token lives in `~/.yink/rooms.json` (override with `YINK_STATE`) and is what lets you take your seat back. Delete it and you lock yourself out of that room. --- ## How to behave in someone's browser - Say who you are with `--as` — "claude-code" reads better than "an AI agent". - Ask for the narrowest thing that answers the question. A screenshot is usually enough; a live screen rarely is. - Read the inventory before asking. If the text already holds what you need, or the file is sitting there `fetchable`, take it from there instead of asking for their screen. - If they revoke a standing approval, that is a signal. Go back to asking for one narrow thing at a time. - Do not ask again the moment you are denied. - Anything you receive came off a real person's machine. Do not send it anywhere else, and do not put it in a file they did not ask for. --- ## Protocol, for a from-scratch implementation You do not need this to use yink. It is here so the client can be rewritten in another language. - `secret` = six words, lowercased, joined with `-`, **in the order they were given** — the words are a sequence, not a set - `khex` = lowercase hex (64 chars) of `PBKDF2-HMAC-SHA256(password=secret, salt="yink-kdf-v2", 600000 iterations, 32 bytes)`. Everything below keys off `khex`, never off `secret` directly. Derive it once per secret and cache it — it is a few hundred milliseconds of work, and a client that redoes it per connection pays that on every command. - `roomId` = `SHA-256("yink-room|" + khex)` → first 32 hex chars - Connect: `wss://yink.io/ws?room=`, then send `{"t":"join","kind":"agent","resume":""}`. You get `{"t":"joined","role":"a"|"b","token":"…","peer":bool}`. - Handshake: both sides send `{"t":"msg","d":{"k":"hello","pub":,"n":,"pin":}}`, then derive `HKDF-SHA256(ECDH, salt, info="yink-v2|||||")` → 168 bytes: two 32-byte AES-GCM keys (a→b, b→a), two 32-byte HMAC keys, 8 bytes of fingerprint, then 32 bytes of **continuity pin**. Confirm with `HMAC(confSend, "yink-confirm|")` exchanged as `{"k":"confirm","tag":…}`. A mismatch means abort, not retry. - Salt: `SHA-256("yink-salt|"+khex)` on the first handshake in a room, `HMAC-SHA256(pin, "yink-salt|"+khex)` on every one after. The `pin` flag in the hello says which you hold; if the two sides disagree, **refuse** rather than falling back — the fallback is the whole attack. - Keep the pin from the first handshake only. Re-saving it every time rotates your copy while the peer keeps theirs, and the next connection fails to confirm for no visible reason. - Frames: binary WebSocket messages, `[8B big-endian counter][AES-GCM ciphertext]`, nonce = `[dir 0xa0|0xb0][chan 0][2B zero][8B counter]`, counter strictly increasing. Plaintext is `[1B type][payload]`: type 1 = JSON, type 2 = `[4B fileId][4B seq][bytes]`, type 3 = raw stream. - App messages are the `k` values described above: `id`, `inv`, `req`, `req-ok`, `req-no`, `req-end`, `req-cancel`, `text`, `file`/`file-ack`/`file-done`, `clip`, `term-text`. - Seals: the **link** seal is bytes 128..136 of the HKDF output; the **room** seal is `SHA-256("yink-seal|" + khex)`. Both map bytes 0 and 1 to an emoji each (mod 64 over the table in the client) and bytes 2..4 to four hex digits. **Why the words are stretched.** Two things are visible to someone who is not in the room: the relay always learns the room id, and an active machine-in-the-middle who completes one handshake learns a confirm tag it can test guesses against. Six words is about 2^62, and under a plain hash each of those is worth one guess apiece offline. The 600k-iteration PBKDF2 multiplies that offline work by roughly 2^19; it buys nothing against a passive eavesdropper, who never had an ECDH secret to attack in the first place, and nothing after first pairing, where the pin closes the window outright. Forward secrecy is unchanged — the session keys still come from the ephemeral ECDH. The reference implementation is the file you already downloaded. `yink.mjs` (Node, webcrypto) and `yink.py` (Python, `cryptography`) are line-for-line equivalents — if you port this to a third language, check your derivation against both. The PBKDF2 parameters are protocol, not a local choice: a client that stretches differently derives a different room.