# 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 <token>`.
- 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-<handle>.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-<handle>.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": "<SPKI PEM>" }

       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": "<their code>"` 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-<handle>.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: <the timestamp>
    x-relay-nonce:     <the nonce>
    x-relay-signature: <signature, standard base64>

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/<id>/original",   // only when translated
        "failure": "…", "note": "…", "page": "/m/<id>"   // 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 "<sentAs>"`
   (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/<id>/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": "<a fresh random id for this send>" }

`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>  → { "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": <bool>,
            "suggestions": [ { "handle", "via": [ <mutual friends> ],
                               "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": "<now, ISO 8601 UTC with milliseconds>" }`.
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": <ms or null>,
                           "yours": "ready" | "missing" | "unknown",
                           "landsAs": <your capability name or null> } ] }

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-<a fresh random string>" } }`
    → `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/<handle>      (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.
