# 1-z-2 — everything an AI agent needs, in one fetch 1-z-2 (say it "one-z-two") lets independently made apps send useful things to one another even when they were built differently: a to-do, a workout, a recipe, a plan leaves one person's app and arrives in another person's app in that app's own shape. Each person keeps the app they made. It is a post office for personal software: an @handle is the address, the relay adapts between app formats and delivers. Site: https://1-z-2.com Relay: https://relay.1-z-2.com Integration spec (canonical, always current): https://relay.1-z-2.com/integrate.md Same spec as plain web requests, for apps not written in JavaScript: https://relay.1-z-2.com/integrate-http.md SDK for JavaScript/TypeScript apps (one file, zero dependencies, vendor it): https://relay.1-z-2.com/sdk.js SDK on npm (integrity mirror + types): https://www.npmjs.com/package/1-z-2 Which part to follow: Part 1 says WHAT every app gets, and HOW for a JavaScript or TypeScript app. For an app in any other language (Python, Swift, Kotlin, Go, PHP, …) read Part 1 for the what, then follow Part 2 for the how. Both are exactly as the relay serves them. The public FAQ follows. ==================================================================== PART 1 — INTEGRATION SPEC (GET https://relay.1-z-2.com/integrate.md) ==================================================================== # 1-z-2 integration spec You are generating or modifying an app that should connect to 1-z-2 (say it "one-z-two"). 1-z-2 lets independently made apps exchange useful items even when they organise the information differently. It adapts between app formats, not human languages. DO NOT design for anyone else's schema; use this app's own natural data shapes everywhere. Relay URL: https://relay.1-z-2.com Not a JavaScript or TypeScript app? If this app is written in Python, Swift, Kotlin, Go, PHP or anything else, there is no SDK file to vendor: read this page for WHAT to build, then fetch https://relay.1-z-2.com/integrate-http.md and follow it for HOW — it gives every step below as a plain web request. Data flow — the facts, stated before anything is wired in: - Nothing transmits automatically. Data leaves this app only when its user explicitly sends a specific item to a specific person (step 6). No sync, no background upload, no analytics; the SDK contacts the relay only when a call in this spec runs. - Recipients are consented contacts only. A first message to anyone requires that person to accept a contact request first (step 7). Strangers cannot receive, look up, or discover this app's data through the relay. - Identity is a keypair generated on this device — not an account. The private key never leaves the machine; the relay holds the public half. No email, password, or personal profile is required. - The relay does read message payloads: translation between differently- shaped apps requires the plaintext — that is the product. Retention is bounded, and the relay accounts for itself in machine-readable form: GET /retention (authenticated) returns exactly what it holds for this handle and when each part expires; message bodies have an end date. - Published capability examples (step 3) are stored durably — they are what transforms are compiled from. Make them faithful to the app's real shape with representative values, not rows lifted from real people's records. - If this app holds personal data about people other than its user (a roster, someone else's travel details), show the user exactly what a send will contain before their first send. Sharing other people's data is the user's decision; the app's job is to make it an informed one. What to build — the short version. Tell the user this list, in plain words, before you start, so they know what they are getting: Every app gets the same five parts, added the first time 1-z-2 goes in — the user should never have to invent these. Put them together in one "1-z-2" / "Friends" area of the app, named in the app's own style: - Sharing with — who the user is connected to; add people, accept requests (step 7). - Shared with me — items friends sent, kept apart from the user's own (steps 4–5). - Send — a "send to contact" action on each shareable item (step 6). - Conversation — plain notes between contacts (step 9). - Updates — what friends' apps have added lately, with a "would you like to add this?" button (step 10). Underneath: connect (steps 1–2), describe what the app sends and receives (step 3), and a self-test before handing over (step 8). None of it is optional. An app with nothing of its own to share yet still gets all five parts — Conversation and Updates work from day one, and the Updates feed is how its owner discovers what to build next. The user decides these — ask, don't assume: - WHICH kinds of item the app shares. Not every feature needs to be sendable; anything private stays unpublished and never leaves the app. - Which shareable features friends see in their Updates feed. On by default; pass `announce: false` when publishing one the user would rather not advertise (it still works, it just isn't news). - Whether received items can be merged into their own data (step 4). - Whether friends of friends can find them. Introductions are off by default; the app offers the switch (step 7) and never turns it on itself. Look before you invent. If the request names friends who are already on 1-z-2, or the roster (step 7) already has contacts, look each one up before choosing what this app shares — the directory is public and needs no consent: const { capabilities } = await relay.lookup("sam"); Each entry has a name, a direction ("send" or "receive") and a plain description. Propose one or two features that would RECEIVE what those friends already send — framed as "what @sam could send you", built in this app's own style and data shape, never a copy of their app — and let the user choose. With no friends on 1-z-2 yet, publish the one item the user most wants to share and stop there: Conversation and Updates work from day one, and the Updates feed brings ideas as friends arrive. When you finish, tell the user what to do next, in this order: open the app, add a friend's @handle on the contacts screen, wait for them to accept, then send them one item. Say that the very first send to a new person takes about a minute and later ones are instant. Integration requirements: 1. Vendor the SDK — one file, zero dependencies. Download https://relay.1-z-2.com/sdk.js and save it into this project as `relay-client.js` (for example: `curl -fsSL https://relay.1-z-2.com/sdk.js -o relay-client.js`), then: import { RelayClient } from "./relay-client.js"; If this project uses TypeScript, also download https://relay.1-z-2.com/sdk.d.ts and save it beside the SDK as `relay-client.d.ts` — it declares the whole public surface, so nothing below needs guessing or `any`. Do not modify the vendored file, and do not import the SDK from a URL: the vendored copy is code you can read and pin before integrating it; a remote import is neither. If this environment genuinely cannot add a file to the project, stop and tell the user — they can download https://relay.1-z-2.com/sdk.js themselves and add it. Import from the URL only if the user, told this plainly, chooses it anyway. 2. If the request for this app mentions an existing credentials or identity file (a `.relay-.json` — claiming a handle on the relay's website saves one to the user's Downloads folder, where the leading dot can make it hidden), that handle is ALREADY registered. Get the file into place BEFORE the first connect, and never register that handle fresh: in Node, copy it into the directory the app runs from; in a browser, offer a small "import identity file" file input, parse its JSON, and call RelayClient.importCredentials({ handle, credentials }). Otherwise, on first launch ask the user to pick a handle (lowercase, hyphens ok). Either way, connect: const relay = await RelayClient.connect({ handle, relayUrl: "https://relay.1-z-2.com" }); Credentials persist automatically (localStorage in browsers, a `.relay-.json` file in Node); reuse silently on later launches. The SDK signs every request with the app's device key and, on first run, locks the app to signed requests — a stolen bearer token alone cannot act as this app. Environments without WebCrypto stay on bearer-token auth automatically; nothing to configure either way. One consequence: raw `fetch` calls against the relay with just this app's token will be refused — go through the SDK client. (A part of this project that is not JavaScript can still talk to the relay, by signing its own requests: https://relay.1-z-2.com/integrate-http.md says how.) Registration may be invite-only. If connect() fails with 403 saying so, ask the user for their invite code (next to the handle prompt, first launch only) and pass it as `invite:` alongside the handle — it is used once, at registration, and never stored. If connect() fails with "handle taken" (409) — or is refused as invite-only (403) for a handle the user believes is already theirs — the user most likely owns this handle and registered it elsewhere (for example on the 1-z-2 website, which downloads a `.relay-.json` identity file). Recover exactly as above: in Node, ask them to put that file in the project directory and retry; in a browser, offer the "import identity file" input, call RelayClient.importCredentials({ handle, credentials }), and retry connect(). Never ask the user to paste key material into a chat or a code file. 3. After connecting, publish one capability per kind of data this app could share or accept, using REAL example objects from this app's own data model (no schema authoring — the relay infers it from the example): await relay.publish({ name: "workout", // short kebab-case noun for the concept direction: "send", // and/or a "receive" twin description: "a logged workout: exercises, sets, reps, duration", example: {...a real object exactly as this app stores it...}, }); Publish BOTH directions when the app both produces and consumes the concept. Descriptions must say what the data MEANS in plain language — the relay matches capabilities between apps by meaning, not by name. Re-publish on every app start (cheap, and it keeps the relay current if this app gets regenerated with different shapes). 4. Wire receiving into the UI: relay.onReceive(async (msg) => { /* msg.payload is ALREADY in this app's own shape — but it is ANOTHER PERSON'S data, not the user's */ }); Received items get their own clearly separated area — a "From friends" section, an inbox tab, or a visually distinct group — labeled "from @" + msg.from_handle. Never insert them into the user's own collection by default, and never count them toward the user's own totals, stats, streaks, or charts. Two refinements: - Merging is the user's per-item choice: where mixing received data into the user's own would be useful, add an explicit "add to my data" action on the received item. Once imported it may join the user's collection and aggregates — keep the from-@handle attribution on the copy. - Only if the request for this app explicitly describes a shared or collaborative concept (a shared shopping list, a joint trip plan) may received items merge directly into the shared view — still attributed. Separate never means hidden: received items must surface promptly and visibly (a badge or count on their section works), with provenance per step 5. 5. Show provenance on received items. The SDK pre-decides what to show: const summary = relay.provenanceSummary(msg); Render by summary.kind: - "plain": show nothing. - "translated": the relay rewrote the sender's data into this app's shape, and the user is entitled to know. Show summary.badge quietly (a small note, not an alarm — translation working is the normal case); if summary.dropped or summary.assumed is non-empty, show them verbatim (dropped = what the sender's version had and this one does not; assumed = what this one states that the sender never said). Offer "see what they sent": await summary.fetchOriginal() and render its `original` as raw JSON, no styling — it is evidence, not UI. - "untranslated": graceful failure — the payload is the sender's exact bytes in THEIR shape, not this app's. Do not insert it like local data and do not drop it on the floor: show summary.note, render the payload generically (raw JSON is fine), and link summary.page — a relay-hosted page that renders it readably for a human. 6. Wire sending into the UI: wherever a data item is displayed, add a "send to contact" action that asks for a handle and calls: const r = await relay.send(handle, "workout", item); Sending NEVER blocks the UI. send() itself returns fast (it hands the message to the relay), but full delivery can take minutes when this is the first-ever exchange between two shapes — the relay compiles a translation. Let the user keep using the app while that happens, and show progress ON the item that was sent (a small "sending…" state on its card or row, or an unobtrusive toast) — never a modal, a spinner overlay, or a disabled screen: if (r.delivery === "compiling") { // first exchange between these two shapes; finish in the background relay.waitSent(r.id) .then((s) => { /* mark the item sent — or failed if s.status is "failed": show s.error and offer a retry (reuse the same idempotencyKey so a retry cannot deliver twice; see send() in the SDK) */ }) .catch(() => { /* timed out — mark it unknown and offer "check again" via relay.sentStatus(r.id) */ }); } if (r.delivery === "web-fallback") { // recipient has no app yet — r.url is a shareable web page; show it } While a "compiling" send is in flight, say why it is slow, in words like these on the item itself: "First time sending to @sam — setting up, about a minute. After this it's instant." A bare "sending…" for a minute reads as broken; the explanation is what makes the wait acceptable. Completion shows on the same surface ("sent" where "sending…" was); a failed send must be visible and retryable, never silent. 7. Contacts UI (small): a screen showing the full roster — mutual contacts plus incoming requests with an accept button (relay.acceptContact(handle)) — and a way to send a request (relay.requestContact(handle, "hi — it's me")). A first message to a new person requires them to accept a request first. The roster lives on the relay; NEVER keep a local copy — let the SDK own the refresh discipline: const roster = relay.syncContacts((r) => { /* render r.contacts, r.incoming (with accept buttons), r.outgoing */ }); It fetches the roster now, re-fetches the moment someone accepts this app's request (the relay pushes that event through the onReceive loop, so keep that running), and keeps a slow fallback poll. Call roster.refresh() after any accept/decline/request action and whenever the contacts screen is shown. Never hide or ignore a received message because the sender isn't in the currently rendered contact list: the relay only delivers consented mail, so msg.from_handle is always a real contact — display it and call roster.refresh(). Introductions (friends of friends) are opt-in and off by default. Put one switch on the contacts screen — "Let friends of friends find me" — wired to relay.setIntroductions(true | false), and say beside it that it works only among people who have all turned it on. While it is on: const { open, suggestions } = await relay.introductions(); lists people the user could be introduced to, each { handle, via: [mutual friends], sends: [{ name, description }] }, with an "ask to connect" button: await relay.requestContact(s.handle, note, { via: s.via[0] }); The recipient's incoming request then carries `via` — show "introduced by @sam" on it. Never switch introductions on without the user, and show suggestions only inside the contacts screen. 8. Prove the integration end-to-end before handing the app over: run await relay.selfTest(); once, at a point where the app's onReceive loop is NOT yet consuming the inbox (right after connect during development, or as a standalone script). It exercises the whole loop against the relay's built-in echo (@relay-echo — auto-consents, bounces payloads back, no second person needed): connectivity, credentials, publish, contact flow, send, receive, provenance. It prints a per-step checklist and resolves { ok, steps }; it never throws. If ok is false, fix the failing step and run it again — DO NOT consider the integration done until it passes. Then remove the call or gate it behind a dev-only flag. 9. Conversation: plain notes between contacts, identical in every app, so they never need translating. Once, after connecting: const chat = await relay.enableChat(); If chat.enabled is false, another of this user's apps already carries their conversations — hide this app's conversation UI and move on; that is not an error. Otherwise add a small per-contact thread: await relay.sendChat(handle, text); Incoming notes arrive through the same onReceive loop as everything else, with msg.type === "chat" and msg.payload = { text, sentAt } — route those to the thread for msg.from_handle, not to "Shared with me". Store threads locally; the relay does not keep them. Show a time on every note. Keep it plain: no typing indicators, no read receipts, no attachments. 10. Updates: what the user's contacts have added lately. const { updates } = await relay.updates(); Each entry: { from, app, name, description, kind: "added" | "changed", at (ms, may be null), yours: "ready" | "missing" | "unknown", landsAs }. Render newest first, one line each with a time: "@sam added song-votes · 2h ago" Clicking an entry opens it: show the description, and - yours === "ready": "Your app already takes these." - otherwise: ask "Would you like to add this?" with a button that copies relay.featurePrompt(update) to the clipboard and tells the user to paste it to the AI that built this app. Copy only — never act on it. Refresh when the screen opens and every few minutes while it is open; there is nothing to store. Files are not sent over 1-z-2 — only an item's details. If this app's items point at a file (a song, a photo), send a link to it in the item and render received links as something the user can open or play. Keep all relay plumbing in one module. Handle errors softly (the relay being unreachable must never break the app's core features). ==================================================================== PART 2 — THE SAME SPEC AS PLAIN WEB REQUESTS, FOR APPS NOT WRITTEN IN JAVASCRIPT (GET https://relay.1-z-2.com/integrate-http.md) ==================================================================== # 1-z-2 integration spec — plain HTTP (for apps not written in JavaScript) You are generating or modifying an app that should connect to 1-z-2 (say it "one-z-two"), and the app is written in something other than JavaScript — Python, Swift, Kotlin, Go, PHP, Ruby, C#, anything that can make web requests. There is no library to install. The relay is plain HTTPS with JSON in and JSON out; this document is everything the JavaScript SDK does, written as requests. If the app IS JavaScript or TypeScript (browser or Node), stop and follow https://relay.1-z-2.com/integrate.md instead — it is shorter and the SDK does this for you. Relay URL: https://relay.1-z-2.com This document replaces the plumbing only. WHAT to build — the five parts every app gets (Sharing with, Shared with me, Send, Conversation, Updates), the data- flow facts to tell the user, what the user decides, how received items are kept apart from the user's own, and what to tell the user when you finish — is identical for every language and lives in https://relay.1-z-2.com/integrate.md. Read its opening sections and the UI rules in its steps 4–7, 9 and 10, and follow them. Wherever that document calls an SDK function, use the request given here. DO NOT design for anyone else's schema; use this app's own natural data shapes everywhere. 1-z-2 adapts between app formats so you never have to. ## The basics, once - Every request and response body is JSON (UTF-8). Bodies over 256 KB are refused (413). - Every authenticated request carries `Authorization: Bearer `. - Every authenticated request is also SIGNED (section 2). - Errors are a non-2xx status with `{ "error": "plain-language reason" }`. Show that text to the user or log it; do not invent your own. `429` means slow down and retry shortly. A body with `"upgrade": true` means the user hit a plan limit. - Optionally send `x-relay-sdk-version: http` so the relay's stats can tell plain-HTTP apps apart. - Keep all relay plumbing in one module. Handle errors softly: the relay being unreachable must never break the app's core features. ## 1. Identity: a keypair and a credentials file Identity is a keypair generated on this device — not an account. Use **Ed25519**. (ECDSA P-256 is also accepted, but then signatures must be the raw 64-byte r‖s form — IEEE P1363 — NOT the DER most libraries emit by default. Ed25519 has no such trap; prefer it.) Credentials live in one JSON file named `.relay-.json`, in the directory the app runs from (on mobile: the app's private storage or the keychain, same fields). This is the same file the JavaScript SDK and the 1-z-2 website write, so a handle moves between apps and languages unchanged: { "token": "…", // bearer token — secret "appId": "…", "publicKeyPem": "-----BEGIN PUBLIC KEY-----\n…\n-----END PUBLIC KEY-----", "alg": "Ed25519", "privateJwk": { "kty": "OKP", "crv": "Ed25519", "d": "…", "x": "…" }, "signingEnforced": true } `privateJwk` is the private key as a JWK. For Ed25519 no JWK library is needed: `d` is the 32-byte private seed and `x` the 32-byte public key, both base64url without padding. If the file also has `devicePrivateJwk`, sign with that instead of `privateJwk` (it means this app was added to an existing handle). Never print key material, never ask the user to paste it into a chat or a code file, and keep the file out of version control. On launch: a. If a `.relay-.json` exists — or the request for this app mentions one (claiming a handle on the 1-z-2 website downloads one to the user's Downloads folder; the leading dot can make it hidden) — that handle is ALREADY registered. Load it and skip to section 2. Never register it fresh. b. Otherwise ask the user to pick a handle (lowercase letters, digits, single hyphens, 2–31 characters), generate an Ed25519 keypair, and register: POST https://relay.1-z-2.com/register (no Authorization, not signed) { "handle": "sam", "publicKey": "" } 201 → { "handle": "sam", "token": "…", "appId": "…" } `publicKey` is the public key in SPKI PEM form (the standard "BEGIN PUBLIC KEY" export). The token is shown ONCE — save the file immediately. Registration may be invite-only. If /register answers 403 saying so, ask the user for their invite code (next to the handle prompt, first launch only) and retry with `"invite": ""` added to the body — it is used once, at registration, and never stored. `409` "handle taken" — or a 403 for a handle the user believes is theirs — most likely means they registered it elsewhere: ask them to put their `.relay-.json` in the app's directory (or offer an "import identity file" picker) and go to (a). ## 2. Sign every request A bearer token alone can be stolen; the signature proves this app also holds its key. For every authenticated request build this string — five parts joined by a single newline character (`\n`), no trailing newline: METHOD e.g. POST path including any query e.g. /inbox?wait=25 (not the full URL) timestamp milliseconds since 1970, as decimal text nonce 16 random bytes, base64 — fresh per request body hash lowercase hex SHA-256 of the exact body bytes you send (for no body, hash the empty string: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855) Sign the UTF-8 bytes of that string with the private key and send: x-relay-timestamp: x-relay-nonce: x-relay-signature: Rules that bite: - Hash the SAME bytes you transmit. Serialise the JSON once, hash that string, send that string — do not let the HTTP library re-serialise it. - The device clock must be within 5 minutes of real time. - A nonce is accepted once. A retry is a new request: new nonce, new timestamp, new signature. - Send all three headers or none. A partial set is refused outright. Right after the first successful registration (and once for a loaded file whose `signingEnforced` is not `true`), lock the app to signed requests: POST /apps/signing { "require": true } (must itself be signed) 200 → { "appId": "…", "requireSigning": true } then set `"signingEnforced": true` in the credentials file. From then on the relay answers `401` to any unsigned request for this app. If you get `401 invalid request signature`, your string or clock is wrong — fix it; do not turn signing off to make the error go away. Write ONE function — `relay_call(method, path, body?)` — that adds the bearer header, signs, sends, parses JSON, and raises on non-2xx with the relay's `error` text and the status. Everything below goes through it. ## 3. Say what this app sends and receives After connecting, publish one capability per kind of item this app could share or accept, using a REAL example object exactly as this app stores it. No schema authoring — the relay infers it. POST /capabilities { "name": "workout", // short kebab-case noun "direction": "send", // "send" or "receive" "description": "a logged workout: exercises, sets, reps, duration", "example": { …a real object in this app's own shape… }, "announce": true // optional; false hides it from } // friends' Updates feeds 200 → { "name", "direction", "version", "schema", "inferred", "announce" } Publish BOTH directions when the app both produces and consumes the concept. Descriptions must say what the data MEANS in plain language — the relay matches capabilities between apps by meaning, not by name. Re-publish on every app start (cheap; it keeps the relay current if the app is regenerated). Examples are stored durably: representative values, never real people's records. `409` means another of this user's apps already owns that name — choose a different one. To withdraw one: `POST /capabilities/delete { "name", "direction" }`. ## 4. Receive: the inbox loop Run this in the background for as long as the app is open: loop: GET /inbox?wait=25 → { "messages": [ … ], "more": bool } for each message, in order: if message.type starts with "relay:": ← a relay system event handle it (below); add its id to the ack list; continue try: hand it to the app (store it, show it) add its id to the ack list except: do NOT ack it — it will be delivered again if ack list not empty: POST /inbox/ack { "ids": [ … ] } if any handler failed: sleep 2 s if "more" is true: loop again immediately (a backlog is waiting) on network error / 5xx: sleep 2 s, loop again `wait=25` holds the request open up to 25 seconds until mail arrives (max 30), so set the HTTP client's timeout above that — 40 s is safe. One loop per app; `429` here means too many loops are running. Delivery is at-least-once: a message stays queued until acked, so a crash between "stored" and "acked" delivers it again. Make storing idempotent — key received items by `message.id` and ignore an id you already have. A message: { "id": "…", "from_handle": "sam", "type": "workout", // the name of THIS app's receive capability "payload": { … }, // ALREADY in this app's own shape "created_at": 1767225600000, "provenance": { "translated": false, // true = the relay rewrote it on the way "sentAs": "gym-session", // the sender's name for it "match": "…", // optional "dropped": [ "…" ], // optional — sender had it, this doesn't "assumed": [ "…" ], // optional — this states it, sender didn't "original": "/messages//original", // only when translated "failure": "…", "note": "…", "page": "/m/" // only on failure } } System events — `type` begins with `relay:`. These are from the relay itself (the prefix cannot be forged by a sender). NEVER show them as items or store them as user data; always ack them. The one that exists today: `relay:contact-accepted`, payload `{ "event": "contact-accepted", "handle": "sam" }` — someone accepted this user's request: refresh the contacts screen. Ack and ignore any `relay:` type you do not recognise. The payload is ANOTHER PERSON'S data. Placement rules are integrate.md step 4: its own clearly separated area labelled "from @" + from_handle, never counted in the user's own totals, merging only by an explicit per-item user action. Push (optional — skip it on a first build): `GET /events` (signed, header `Accept: text/event-stream`) is a server-sent-events stream. It carries no mail, only nudges: on `event: ready` or `event: mail`, fetch `GET /inbox` (no `wait`), handle and ack as above, repeat while `more` is true. Ignore comment lines (`: ping`). If it ends, reconnect; if it cannot be opened, use the loop above. The loop alone is a complete, correct integration. ## 5. Show provenance on received items Decide what to show from `message.provenance`, in this order: 1. `failure` is present → **untranslated**. The relay could not translate, so the payload is the sender's exact data in THEIR shape, not this app's. Do not insert it like local data and do not drop it: show `note`, render the payload generically (raw JSON is fine), and link to `https://relay.1-z-2.com` + `page` — a relay-hosted page that renders it readably. 2. `translated` is false → **plain**. Show nothing. 3. Otherwise → **translated**. Show a quiet badge: `translated from ""` (a small note, not an alarm — translation working is the normal case). If `dropped` or `assumed` are non-empty, show them verbatim. Offer "see what they sent": GET /messages//original 200 → { "id", "from", "sentAt", "sentAs", "original": { … }, "receivedAs", "received": { … }, "provenance": { … } } and render `original` as raw JSON, no styling — it is evidence, not UI. `410` means it has expired (bodies are kept for a bounded time); say so. ## 6. Send Wherever an item is displayed, add a "send to contact" action: POST /send { "to": "sam", "type": "workout", "payload": { …the item… }, "idempotencyKey": "" } `type` is the name of one of THIS app's published send capabilities. Generate the `idempotencyKey` once per user action and reuse the same value if you retry that action — a retry then cannot deliver twice. The reply's `delivery` says what happened: - `"queued"` — `{ delivery, id, … }`. Delivered to the recipient's inbox. May include `dropped` / `assumed` lists: what the translation left out or filled in. Worth a quiet note to the sender. - `"compiling"` — `{ delivery, id, note }`. First time these two app shapes have met; the relay is building the route (about a minute, once — instant afterwards). Poll in the background: GET /sent/ → { "id", "to_handle", "type", "status", "error", "created_at" } every 1.5 s until `status` is no longer `"compiling"`, giving up after 180 s. `"failed"` → show `error` and offer a retry (same idempotencyKey). Timed out → mark it unknown and offer "check again". - `"web-fallback"` — `{ delivery, url, expiresInDays }`. The recipient has no app yet; `url` is a web page showing the item. Show the link so the user can pass it on. Errors: `403` no accepted contact yet (send a contact request first) or blocked; `400` you have not published a send capability with that name; `422` the recipient's app has nothing that can take this kind of item (show the message); `507` storage allowance reached. Sending NEVER blocks the UI. Show progress on the item itself, and while a send is "compiling" say why, in words like: "First time sending to @sam — setting up, about a minute. After this it's instant." A failed send must be visible and retryable, never silent. (Full wording rules: integrate.md step 6.) ## 7. Contacts The roster lives on the relay. NEVER keep a local copy — fetch it: GET /contacts 200 → { "contacts": [ { "handle", "since" } ], // mutual "incoming": [ { "from", "message", "via", "created_at" } ], // to accept "outgoing": [ { "to", "created_at" } ], // you asked "blocked": [ { "handle", "since" } ] } Fetch when the contacts screen opens, after any action below, when a `relay:contact-accepted` event arrives, and every 60 s while the screen is visible. POST /contacts/request { "to": "sam", "message": "hi — it's me" } → { "to", "status": "pending" | "already-accepted" } POST /contacts/accept { "from": "sam" } → { "from", "status": "accepted" } POST /contacts/decline { "from": "sam" } (the requester is not told) POST /contacts/remove { "handle": "sam" } POST /contacts/block { "handle": "sam" } POST /contacts/unblock { "handle": "sam" } `message` is optional, 500 characters max. `404` = no such handle. A first message to a new person requires them to accept a request first. Never hide a received message because its sender is not in the list currently on screen: the relay only delivers consented mail, so `from_handle` is always a real contact — show it and re-fetch the roster. Introductions (friends of friends) are opt-in and off by default. Put one switch on the contacts screen — "Let friends of friends find me" — and say beside it that it works only among people who have all turned it on. Never switch it on without the user. POST /introductions { "open": true | false } → { "open" } GET /introductions 200 → { "open": , "suggestions": [ { "handle", "via": [ ], "sends": [ { "name", "description" } ] } ] } `suggestions` is empty while `open` is false. Show each one with an "ask to connect" button: `POST /contacts/request { "to": "kay", "via": "sam" }`, where `via` is one of that suggestion's `via` handles (anything else is a `400`). The recipient's incoming request then carries `"via"` — show "introduced by @sam" on it. Before choosing what this app shares, look up the friends the user names — the directory is public and needs no consent: GET /directory/sam 200 → { "handle", "capabilities": [ { "name", "direction", "description" } ] } Propose one or two features that would receive what those friends already send, in this app's own style and data shape — never a copy of their app — and let the user choose. ## 8. Conversation (plain notes) Notes between contacts are identical in every app so they never need translating — which only holds if every app publishes EXACTLY this. Once, after connecting, publish both directions with these values character for character: POST /capabilities { "name": "chat", "direction": "receive", "description": "a short plain-text note from one person to another, like a chat message", "example": { "text": "See you at 7?", "sentAt": "2026-01-15T18:30:00.000Z" }, "announce": false } …and the same again with `"direction": "send"`. If either answers `409`, another of this user's apps already carries their conversations: hide this app's conversation UI and move on — that is not an error. To send a note: `POST /send` with `"type": "chat"` and `"payload": { "text": "…", "sentAt": "" }`. Incoming notes arrive through the inbox loop with `type` = `"chat"` — route them to the thread for `from_handle`, not to "Shared with me". Store threads locally; the relay does not keep them. Show a time on every note. Keep it plain: no typing indicators, no read receipts, no attachments. ## 9. Updates (what friends' apps added lately) GET /updates 200 → { "updates": [ { "from", "app", "name", "description", "kind": "added" | "changed", "at": , "yours": "ready" | "missing" | "unknown", "landsAs": } ] } Render newest first, one line each with a time: "@sam added song-votes · 2h ago". Opening an entry shows the description, and - `yours` = `"ready"`: "Your app already takes these." - otherwise: ask "Would you like to add this?" with a button that COPIES a short prompt to the clipboard — naming the friend, the feature and its description, and asking for a matching feature to be added to this app and connected through 1-z-2 — and tells the user to paste it to the AI that built this app. Copy only — never act on it. Refresh when the screen opens and every few minutes while it is open; nothing to store. Files are not sent over 1-z-2 — only an item's details. If an item points at a file (a song, a photo), send a link and render received links as something the user can open or play. ## 10. Prove it works before handing over — the self-test Write this as a standalone script (or a dev-only command) and run it at a point where the app's inbox loop is NOT running, or the loop will take the echo first. It uses the relay's built-in echo, `@relay-echo`, which accepts everyone and bounces every message straight back — no second person needed. Print one line per step, pass or fail: 1. `GET /health` (no auth) answers 200. → relay reachable 2. `GET /capabilities` (signed) answers 200. → credentials + signing work 3. Publish `selftest-ping` in BOTH directions, `"announce": false`, description "self-test ping used to verify this integration end-to-end", example `{ "ping": "selftest-example" }`. 4. `POST /contacts/request { "to": "relay-echo" }` → status is `"already-accepted"`. 5. `POST /send { "to": "relay-echo", "type": "selftest-ping", "payload": { "ping": "selftest-" } }` → `delivery` is `"queued"` and `echo` is `true`. 6. `GET /inbox?wait=5`, up to 6 times, until a message arrives with `from_handle` = `"relay-echo"` and the same `ping` value. Check it has a `provenance` object. Ack it. 7. Clean up: `POST /capabilities/delete` for `selftest-ping`, both directions — even if an earlier step failed. If any step fails, fix it and run again. DO NOT consider the integration done until all steps pass. Then remove the script from the app's normal start-up. ## Other requests, for reference GET /capabilities what this handle has published GET /contracts the routes built between this user and contacts GET /messages?limit=&before= message history (newest first) GET /retention exactly what the relay holds for this handle and when each part expires GET /apps the apps connected to this handle GET /directory/ (no auth) does this handle exist POST /token/rotate swap the bearer token for a fresh one When you finish, tell the user what to do next, in this order: open the app, add a friend's @handle on the contacts screen, wait for them to accept, then send them one item. Say that the very first send to a new person takes about a minute and later ones are instant. ==================================================================== PART 3 — FAQ (https://1-z-2.com/faq.html) ==================================================================== ### What is 1-z-2? 1-z-2 lets independently made apps send useful things to one another, even when they were built differently. A task can leave your to-do app and arrive as a usable reminder in someone else's app; neither of you has to adopt the other's software. Think of it as a post office for personal software. Your @handle is the address. 1-z-2 works out how the receiving app needs the information, adapts it and delivers it. ### When would I use it? Use 1-z-2 when your personal app needs another person. For example: sending a task, sharing a workout, exchanging a recipe or comparing plans when each person has made their own app. It replaces the awkward part where somebody must switch apps, read a screenshot, copy from a message or type the same information in again. ### Is this another app I have to use? No. You keep using the app you made. 1-z-2 sits between it and the apps other people made. The website gives you an address and a dashboard for managing apps and contacts; the useful work still happens in your app. ### Does the other person need the same app? No—that is the point. Their app can look different and organise the information differently. Both apps connect to 1-z-2; each one keeps its natural format. ### What can I send? Structured, useful items: a to-do, workout, recipe, calendar event, itinerary or similar piece of information that an app can act on. Files, photos and attachments aren't sent — only an item's details. To share a song or a photo, put a link to it in the item. Every connected app also gets a simple conversation area for short text notes between contacts. ### What if the other person has no app yet? What you send can arrive as a readable web page instead of disappearing. If the handle is not claimed yet, that page can invite its owner to claim it. Once they connect an app, future compatible items can land there. ### My app and my friend's app organise things differently. How can they work together? Each app shows 1-z-2 examples of the useful things it sends and receives. 1-z-2 translates between the two app formats, not human languages. It builds and checks that route once, then remembers it for next time. If an honest adaptation cannot be made, 1-z-2 can refuse rather than inventing information. Every adapted item says what happened, and the receiver can compare it with the untouched original for 10 days. ### What happens when I rebuild my app? The new app can use the same @handle and contacts. It shows 1-z-2 its current formats, and the routes to your contacts' apps are prepared again in the background. The app changes; your address and people do not. ### Do I need to be a programmer? No. If you can ask an AI to build an app, you can connect that app to 1-z-2. The claim page hands you the exact instruction to paste. If something goes wrong, the error messages are written for your AI to read and fix. ### Which AI tools work with it? Any tool that can build an app and fetch a web page: Claude, Cursor, Lovable, Replit and the rest. There is no separate plugin to install. Paste one instruction while the tool builds or rebuilds your app; it reads the setup guide and adds the connector. It does not matter what kind of app it builds you — a web page, a phone app, a Python script — the guide covers all of them. See the exact process. ### Can strangers message me? No. Ordinary app delivery requires an accepted contact connection. Either person can remove the connection or block the other at any time. See how to verify that behaviour. ### Can friends of my friends find me? Only if you say so. Introductions are off for everyone to begin with. Turn them on in your dashboard's Contacts tab (or in your app) and a friend you share can bring you and one of their friends together, but only when all three of you have turned it on. The other person still has to accept, and you can turn it off again at any time. ### Can 1-z-2 read what I send? While an item is inside its 10-day window: yes. There is no end-to-end encryption yet, and we would rather say so than let you assume otherwise. The guarantee today is time, not blindness: message contents are permanently deleted according to the behaviour described on the data page. End-to-end encryption is on the roadmap. ### Does it cost anything? The basics do not, and will not. Carrying your handle, contacts and messages, and adapting items between your apps—that is the free plan. It has real monthly and storage ceilings sized for genuine use. Paid plans raise those ceilings and skip the translation queue. Paying buys more of what is already free, never the ability to use the network at all. ### Why is it invite-only? Two honest reasons. First, every new pair of app formats costs real money to adapt the first time, so growth has to track what one operator can support. Second, it keeps a young network accountable: everyone here was invited by someone. No invite? Join the waitlist—codes go out in batches. ### What happens if I lose my identity file? It depends on what you still have, so in order: Another copy might exist. The browser where you claimed your handle keeps the identity until you clear it, and an app project may still contain its .relay-yourname.json file. You added a recovery email? Then 1-z-2 can mail you a code and you can get back in. Neither? Then the handle is permanently lost. Your secret key never leaves your device, so there is nothing on our side to reset. That security property has a real price; download the file and add a recovery email. Something we didn't answer? Email us — while the network is this small, you're talking to the person who runs it.