Skip to content

Relay Protocol

This page documents the wire protocol used between Cinch clients (the cinch CLI, the desktop app) and the relay server. Wire DTOs are defined as Protocol Buffers in cinchcli/cinch’s crates/client-core/proto/cinch/v1/ and shared verbatim across Rust and Go via generated bindings + a JSON wire-vector gate. The relay consumes the same bindings from the go/cinch/v1/ module path.

The relay does not use a shared bearer token. Each device authenticates with its own per-device token issued through the device-code flow (or via OAuth on the browser sign-in page).

Every authenticated HTTP request carries:

Authorization: Bearer <per-device-token>

WebSocket connections authenticate the same way at upgrade time, or by exchanging the token for a short-lived ticket (POST /ws/ticket) and connecting with ?ticket=... (used by browsers that cannot set custom upgrade headers).

In addition, all authenticated requests should send the client’s version so the relay can populate devices.client_version / devices.client_type:

X-Cinch-Client-Version: 0.1.10
X-Cinch-Client-Type: cli # "cli" | "desktop"

WebSocket clients send the same info as the first frame after auth via the client_hello action (see below).

MethodPathDescription
POST/clipsPush a new text-style clip (text / code / url). Body is a PushClipRequest JSON. Returns the assigned clip ID + retention info.
POST/clips/binaryPush an image / binary clip. multipart/form-data upload; payload streamed to the media backend.
GET/clips/latestGet the latest clip for the caller’s account. Optional ?source= / ?exclude_source= / ?exclude_image= / ?exclude_text= filters.
GET/clipsList clips with ?limit= and ?since= cursor.
GET/clips/{id}/mediaFetch raw bytes for a binary clip.
POST/clips/{id}/pinPin or unpin a clip. Broadcast to other devices via WS.
DELETE/clips/{id}Delete a clip. Creates a tombstone for offline devices.
GET/tombstonesList deletion tombstones since a timestamp (offline sync).
POST/pullRequest the latest clip from another device (server-initiated WS round-trip). Used by cinch pull --from.
MethodPathAuthDescription
POST/auth/device-codenoneIssue a new device-code (start of cinch auth login). Returns a user_code + verification URL.
GET/auth/device-code/pollnonePoll for completion of a device-code. Returns the device token once approved.
POST/auth/device-code/completebearerApprove a pending device-code from this signed-in device (cinch auth approve).
GET/auth/browsernoneBrowser sign-in page (OAuth or self-host username form).
GET/auth/providersnoneLists configured OAuth providers for the sign-in UI.
MethodPathDescription
POST/auth/device/public-keyRegister this device’s X25519 public key + fingerprint.
POST/auth/key-bundlePost an encrypted user-key bundle to another device.
GET/auth/key-bundleRetrieve the bundle posted to this device.
POST/auth/key-bundle/retryAsk another paired device to re-share the user key (cinch auth retry-key).
MethodPathDescription
GET/devicesList devices on the caller’s account (includes client_version, client_type, last_push_at).
PUT/devices/{id}/nicknameRename a device.
PUT/devices/self/retentionSet the calling device’s local retention preference.
POST/auth/device/revokeRevoke another device.

Requires a bearer token belonging to a user where users.is_admin = true.

MethodPathDescription
POST/admin/invitesMint a new invite token.
GET/admin/invitesList invites.
DELETE/admin/invites/{hash}Revoke an invite.
GET/admin/usersList users.
DELETE/admin/users/{id}Delete a user (revokes all their devices + clips).
MethodPathDescription
GET/healthReturns 200 OK with a JSON status payload. No auth required.

Upgrade to WebSocket for real-time clip delivery and bidirectional control messages. Every frame is a JSON-encoded WSMessage envelope.

Upgrade request

GET /ws HTTP/1.1
Upgrade: websocket
Connection: Upgrade
Authorization: Bearer <per-device-token>

For browser clients that cannot set custom upgrade headers, first call POST /ws/ticket to mint a short-lived ticket and connect with wss://relay/ws?ticket=<ticket>.

{
"action": "new_clip", // see action vocabulary below
"clip": { /* cinch.v1.Clip */ }, // new_clip / clip_deleted
"pull_id": "...", // send_clipboard / clipboard_content correlation
"content": "...", // clipboard_content payload (encrypted ciphertext)
"error": "...", // error frames
"reason": "...", // revoked: why
"token": "...", // token_rotated: new per-device token
"device_id": "...", // token_rotated / context
"hostname": "...", // token_rotated / device_code_pending
"device_key_fingerprint": "...", // key_exchange_requested: fingerprint to confirm
"user_code": "...", // device_code_pending: code shown to approver
"requested_at": 1714000000, // device_code_pending: unix seconds
"source_region": "fra", // device_code_pending: best-effort GeoIP
"client_hello": { // client_hello: agent → relay
"version": "0.1.10",
"type": "cli" // "cli" | "desktop"
}
}

The Go definition lives in relay/internal/protocol/ws.go; the Rust counterpart is in cinchcli-core’s protocol.rs. Both are gated by testdata/wire-vectors.json for byte-equal round-trip.

actionDirectionPurpose
client_helloagent → relayFirst frame after auth. Reports binary version + client type.
new_cliprelay → agentA new clip is available. Carries clip.
clip_deletedrelay → agentA clip was deleted. Carries clip (with ID only).
clip_pinnedrelay → agentA clip’s pin state changed.
send_clipboardrelay → agentServer-initiated pull request. Carries pull_id.
clipboard_contentagent → relayResponse to send_clipboard. Carries pull_id + content.
ping / pongbothKeepalive.
revokedrelay → agentThis device has been revoked. Carries reason.
token_rotatedrelay → agentNew per-device credentials issued (migration from legacy master token).
key_exchange_requestedrelay → agentA new device is requesting the user-key bundle. Carries device_key_fingerprint for out-of-band verification.
device_code_pendingrelay → desktopPush-approval notification for a remote cinch auth login. Carries user_code, hostname, requested_at, source_region.

Keepalive. The relay sends a WebSocket ping every 30 seconds. Clients that do not respond within 10 seconds are disconnected. The desktop app and the CLI reconnect automatically with exponential back-off (initial 1s, max 60s).

Clips carry one of exactly four canonical content_type strings. The wire vocabulary is fixed; adding a value is a coordinated proto + cinch-core publish + version-bump cycle.

ValueWhen emitted
imageMagic-byte sniff matched PNG / JPEG / GIF / WebP, or the client explicitly forced --type image/*.
textDefault for any non-image input.
urlThe whole payload parsed as a single URL.
codeMatched a shebang, JSON shape, or code heuristic in client_core::classify::detect.

There are no MIME-style strings on the wire. Producers run classify::detect and emit the 4-string value; consumers trust it. Both the relay (relay/internal/protocol) and cinchcli-core enforce this constant set.