Intermezzo
Open protocol · version 1

Build a break timer that speaks Intermezzo.

Any screen can join the same break schedule. This guide takes you from “I can open a TCP socket” to a compatible client—without assuming Apple frameworks or prior protocol experience.

Bonjour · TCP · JSON · HMAC-SHA256 · no central server

local network · authenticated
Intermezzo Mac
_intermezzo._tcp
Your client any platform
"kind": "event", "type": "start",
"date": 1750000000.25, "duration": 20
The whole protocol in one sentence

Find peers with Bonjour, exchange one-line JSON over TCP, verify every line with the same shared key, then act at the absolute time in the message.

See the four parts ↓

First, the mental model

Four ordinary technologies, used together.

There is no Intermezzo server and no account handshake. Nearby devices form a tiny peer-to-peer mesh on the user's own network.

01 Find

Bonjour advertises where each client is listening.

02 Connect

Peers open ordinary, long-lived TCP connections.

03 Trust

A shared 32-byte key authenticates every message.

04 Synchronize

Absolute timestamps make every screen act together.

You can ignore iCloud completely.

Apple clients also use iCloud when devices are apart, but it is a separate transport. Two clients with the same key on one LAN are a complete Intermezzo system.

Choose your scope

Start small. Grow into a full peer later.

A useful first version only needs to receive breaks. Sending, settings sync, and six-digit pairing can all come afterwards.

Complete experience

Full peer

Can start and postpone breaks, recover after sleep, and synchronize settings.

  • Browse and dial other peers
  • Send events and schedules
  • Apply settings and session state
  • Reconnect and announce state
  • Optionally pair with six digits

The shortest path to a working receiver

01

Create an identity

Generate any stable-per-install string and persist it. Intermezzo uses an uppercase UUID. This is not secret; it only identifies your own messages and clock offset.

02

Paste the fleet key

Let the user paste the key shown in Intermezzo under Settings → Advanced → Device pairing → Pairing key. Trim whitespace, decode standard base64, and require exactly 32 bytes.

03

Advertise your listener

Open TCP on any available port and advertise _intermezzo._tcp in the default local. domain with Bonjour, Avahi, or Android NSD.

04

Read one JSON object per line

Buffer TCP bytes until \n (0x0A). Decode that line as UTF-8 JSON. Skip malformed lines without closing the connection.

05

Authenticate before acting

Rebuild the canonical string described below, calculate HMAC-SHA256 with the shared key, and compare the tag in constant time.

06

Answer time requests; show breaks

Reply to timeReq so senders can measure your clock. For event/start, convert the sender's timestamp and schedule the break.

That is already a compatible client.

You do not need to browse, dial, sync settings, use iCloud, or implement code pairing to receive synchronized breaks correctly.

The wire reference

Discovery, connection, and framing.

The transport is intentionally boring: Bonjour tells you where to connect; TCP carries small JSON objects.

serviceDNS-SD type

_intermezzo._tcp in the default local. domain

transportprotocol

Plain TCP to the advertised host and port; connections are long-lived

framingencoding

One UTF-8 JSON object followed by one line feed byte, 0x0A

line limitrecommended

64 KiB is generous; normal messages are only a few hundred bytes

reconnectbehavior

Redial after about 2 seconds while the peer remains advertised

Two connections per peer is normal.

Every full peer both listens and dials, so A may connect to B while B connects to A. Keep both. Duplicate delivery is part of the protocol.

Be silent when synchronization is off.

Only advertise, browse, or keep sockets open while the user has synchronized breaks enabled. TCP is not encrypted; HMAC authenticates the low-sensitivity timing payload but does not hide it.

TCP stream
// Reads may split or combine lines; buffer until \n.
{"kind":"timeReq","t1":1750000000.125,"auth":"…"}\n
{"kind":"event","type":"start","date":1750000000.25,"duration":20,"origin":"ABC","auth":"…"}\n

The shared message object

Every message is one flat object. Only kind is universal; missing and JSON null fields are equivalent.

kindstring · all

event, timeReq, timeResp, settings, session, or schedule

typestring

Event action or session action

datedouble

Unix epoch seconds in the sender's clock

durationdouble

Break length in seconds; zero for postpone

originstring

Sender's stable device ID

t1 · t2 · t3doubles

Clock-handshake timestamps

responderstring

Clock responder's device ID

settingsobject

Name → {"value": string, "modifiedAt": double}

authbase64 string

HMAC-SHA256 tag; required on every sync message

What each message means

Two required ideas. Four optional upgrades.

A receiver needs events and clock replies. The other kinds make a full peer recover and converge gracefully.

event / startcore

A break begins at date and lasts duration seconds. Ignore your own origin. After clock conversion, drop only if the whole break has elapsed.

timeReq → timeRespcore

Measures the clock difference between peers. A receive-only client must answer requests; it need not initiate them.

event / postponecontrol

Moves the next break to date; duration is zero. Ignore the event when its converted date is already past.

scheduleoptional

Announces the next break after reconnect or wake. When both sides count down, the earlier future schedule wins.

settingsoptional

Synchronizes string values with per-key edit times. Apply a known value only when its converted stamp is strictly newer.

sessionoptional

Carries stop, pause, resume, or skip. Ignore impossible actions and events more than about five minutes from now.

Exact convergence and replay rules
  • Start events are normally sent 0.5 seconds ahead so all screens can schedule the same instant.
  • Send a full settings map when a connection becomes ready; send changed entries after local edits.
  • Convert every settings modifiedAt to local time. Per key, strictly newer wins. Unknown or invalid values are ignored without replacing the local stamp.
  • Applying remote settings, sessions, schedules, or postpones must never rebroadcast the same change.
  • Adopt a peer schedule only when it is in the future, your client is currently counting down, and it is earlier than yours by more than about one second.
  • Send schedules on connection, foreground refresh, and local schedule changes.

The compatibility-critical part

Sign the meaning, not the JSON text.

JSON allows different key orders and number spellings. Intermezzo therefore signs one deterministic string built from the message fields.

Never calculate the HMAC over serialized JSON.

Build the canonical string below as UTF-8, calculate HMAC-SHA256 with the raw 32-byte fleet key, then base64-encode the 32-byte result.

canonical core · exactly nine fields
kind | type | date | duration | origin | t1 | t2 | t3 | responder

A complete test vector

If your implementation produces this tag, your key handling, number formatting, canonicalization, HMAC, and base64 agree with Intermezzo.

known-answer test
# Key: bytes 0x00 through 0x1f, encoded for display
AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=

# Unsigned message
{"kind":"event","type":"start","date":1750000000.25,
 "duration":20,"origin":"ABC"}

# Canonical UTF-8 input
event|start|1750000000.250000|20.000000|ABC||||

# Expected base64 HMAC-SHA256
f6HxT7jGz/vfHyprGJfpwP6+GwgjHVYLN5Esf8qd0gI=
Canonical suffixes for settings, session, and schedule

The original kinds—event, timeReq, and timeResp—stop after the frozen nine fields. Every other known kind appends its kind name as field ten. session and schedule stop there.

For settings, append one field per entry sorted by setting name. Render each as name=value@modifiedAt, again with exactly six decimals:

settings suffix
…|responder|settings|chimeTheme=bells@1752170000.250000|workIntervalMinutes=20@1752170003.000000

Unknown kinds must be ignored, never treated as events. A missing, malformed, or incorrect tag means the entire message is dropped.

Make the screens move together

Correct for the peer's clock.

Every date is Unix epoch seconds according to the sender. A small SNTP-style exchange estimates how far that clock is ahead or behind yours.

Requestersends at t1 · receives at t4
timeReq →
← timeResp
Responderreceives at t2 · sends at t3

The requester sends timeReq with t1 = now. The responder records t2 on receipt and replies immediately with the same t1, t2, t3 = now, and its device ID in responder. The requester records t4 when that reply arrives.

clock conversion
offset = ((t2 - t1) + (t3 - t4)) / 2   // peerClock - myClock
localDate = senderDate - offset[origin]

A timeResp must echo the latest outstanding t1 on that connection; otherwise ignore it as stale. Store the result by responder device ID. Use zero until you have a measurement. Full peers initiate on connection and roughly every two minutes.

Delivery, duplicates, and stale data
  • Broadcast to every currently ready socket. There is no queue, acknowledgement, or retry.
  • Duplicate delivery is normal because peers often have two sockets and Apple clients may also receive through iCloud.
  • Make event handling idempotent: scheduling the same fire instant twice must be harmless.
  • Drop a start only when its converted fire time plus duration is already past.
  • Drop a postpone or schedule when its converted date is not in the future.
  • On failure, redial after about two seconds while advertised; also reconcile on wake and about every two minutes.

Getting the shared key

Manual paste first. Six-digit pairing when you're ready.

Wire compatibility begins once two devices hold the same 32 bytes. Manual paste is the universal fallback and the sensible first implementation.

Polished onboarding

Use a six-digit code

Run a CPace password-authenticated key exchange, then receive the real key over the resulting encrypted channel.

  • One short, expiring code
  • No permanent key in the QR
  • Safe against offline guessing
The six digits are a PAKE password—not an encryption key.

Do not “simplify” pairing by encrypting the fleet key with something derived directly from six digits. That allows instant offline brute force. If CPace is unavailable, keep manual paste.

Exact six-digit pairing transport and exchange

The host advertises _intermezzo-pair._tcp only while its code is visible. The TXT record contains id and name. Framing is newline-delimited JSON. Pairing messages have no auth field because the shared key does not exist yet.

Joinernew device · initiator A
hello →
← response
confirm →
← key
Hostholds key · responder B
hellojoiner → host

sid (16 bytes), share (Ya, 32 bytes), ad (device ID), name

responsehost → joiner

share (Yb, 32 bytes), ad, name

confirmjoiner → host

tag: base64, 32-byte joiner confirmation value

keyhost → joiner

box: base64 ChaCha20-Poly1305 combined box, 60 bytes total

failhost → joiner

reason: diagnostic text; current wrong-code value is badCode

  1. Joiner chooses sid and ephemeral scalar ya, derives the code-dependent generator, calculates Ya, and sends hello.
  2. Host derives the same generator, chooses yb, calculates Yb, sends response, and derives the intermediate session key. Abort on a low-order point.
  3. Joiner derives the same intermediate key and sends the confirmation value.
  4. Host compares in constant time. On success it seals the 32-byte fleet key and sends key; on mismatch it sends fail. Either result burns the code.
  5. Joiner opens the box, requires 32 plaintext bytes, and pins the recovered key. Opening the box authenticates the host.
Exact CPace ciphersuite and key schedule

Use draft-irtf-cfrg-cpace, ciphersuite CPACE-X25519-SHA512. The joiner is always initiator A, the host responder B, and transcript ordering is transcript_ir.

groupX25519

RFC 7748; scalar multiplication must abort on low-order points

hashSHA-512

Input block size 128

generator DSIUTF-8

CPace255

ISK DSIUTF-8

CPace255_ISK

mapElligator 2

Non-square Z = 2, RFC 9380

CIUTF-8

intermezzo-pair-v1

ADa / ADbUTF-8

Joiner's / host's device ID

passwordASCII

All six digits, preserving leading zeroes

key schedule · lv_cat uses LEB128 lengths
ISK = SHA-512(
  lv_cat(DSI_ISK, sid, K)
  || lv_cat(Ya, ADa)
  || lv_cat(Yb, ADb)
)

# HKDF-SHA256, empty salt, 32-byte output
confirm_info = "intermezzo.pair.v1.confirm.initiator"
seal_info    = "intermezzo.pair.v1.seal"
Code lifecycle, QR payload, and key pinning
  • Generate all six digits uniformly with rejection sampling and preserve leading zeroes.
  • Expire after 120 seconds and stop advertising.
  • Success or wrong code burns the code. Issue a fresh code after failure; stop after five failures until the host user restarts.
  • QR payload: intermezzo://pair?c=482913&d=<device-id>. Never put the permanent fleet key in the QR.
  • A pasted or code-paired key is pinned. Automatic cloud key merging must never replace it; a later explicit pairing may.
  • There is no key rotation in v1.

Apple clients also store an unpinned key under intermezzo.sync.key.v1. If two unpinned Apple devices generated different values, the lexicographically smaller base64 text wins. Third-party clients do not need this iCloud behavior.

Before you call it compatible

A practical conformance checklist.

Test the smallest useful behavior first, then add the full-peer items without changing the core wire format.

Full peer

Add these

  • Browse and dial every advertised peer
  • Offsets stored by device ID
  • Start and postpone broadcasts
  • Reconnect and state announcement
  • Settings, session, and schedule rules
  • Optional CPace code pairing
If messages arrive but nothing happens, check the HMAC first.

The usual cause is a different key or canonicalization mismatch—especially locale-dependent decimals, missing empty fields, or signing raw JSON. Then check clock conversion and stale-event rules.

Building a client? We'd like to hear about it.

Interop questions, test reports, and new platform ports are welcome at support@tryintermezzo.app.