# Presence

> Presence is a signed claim a device makes about itself, carried by a frame the relay never stores. There is no server-side last seen.

Source: https://getweald.com/docs/protocol/presence
Last modified: 2026-08-04

---

## A signed claim

Last updated 2026-08-04

Presence is a signed claim a device makes about itself, carried by a frame the relay never stores. It is not a fact the relay computes, and there is no server-side last seen. A relay that could synthesise presence could show a person as at their keyboard when they were not, which is a small lie with a large use.

## The LIVE frame

Protocol version 2 adds frame tag 21, the same shape in both directions: a group, an epoch and a sealed body. The body is a LiveBody, signed by the device it describes with the same Ed25519 key that signs envelopes, and the relay cannot read it. What the relay sees is a group id, a size and a moment.

Nothing about a beat is durable. It is never given a sequence number, never written to Postgres, never returned by a reconciliation round, never named in a head attestation or a checkpoint, and never named in a compaction manifest. A beat published into a group nobody is watching is discarded, which is normal rather than an error.

## Why a frame and not an event kind

The kind space reserved 0x00F0 ephemeral for exactly this and it was never implementable. Under enc: 1 an envelope's kind lives inside the ciphertext, so a relay instructed to drop one kind and keep every other cannot tell them apart. That is the same hole the WRAP and HANDSHAKE frames were pulled out of the envelope to close, and the answer here is the same one.

0x00F0 stays allocated forever, unused.

## What a beat claims

*The LiveBody fields*

| Field | Meaning |
| --- | --- |
| kind | 1 presence, 2 typing. 3 and 4 are reserved and never sent |
| member | the author's device key, the same 32 bytes an envelope carries |
| state | 1 active, 2 idle, 3 away. Presence only |
| channel | the channel slug. Typing only |
| at | the author's clock, in milliseconds |
| ttl | how long the claim is good for, in seconds |
| cert | the delegation certificate, when the author speaks under a delegated key |
| sig | Ed25519 over the canonical body |

at is compared and never rendered. It is used for one decision, which is to discard a beat older than one already held from the same device, so a replayed beat cannot mark somebody away. What a member list shows as a last seen is computed by the reader's own machine from the newest envelope it actually observed.

ttl is clamped by the receiver at 120 seconds. Without the clamp a single beat claiming a year would leave a dot on a laptop that has been shut since, and the clamp is at the receiver rather than the sender because the sender is the party with the motive.

## Limits

*What bounds the ephemeral path*

| Limit | Value | Why |
| --- | --- | --- |
| ct size | 4 KiB | A beat is a signed struct, not a payload |
| frames per connection per minute | 60 | Budgeted separately from the envelope allowance, so presence can never starve a durable write |
| client beacon interval | 20 seconds | Also why a shed frame needs no error |
| receiver ttl clamp | 120 seconds | A sender cannot claim to be present indefinitely |

A refused beat is refused on the frame. The connection stays up and durable traffic is unaffected.

## Shedding is not downgrading

A subscriber whose send queue is full is normally downgraded to reconciliation and told so, because a dropped envelope is a hole in an author chain and therefore a security alarm on somebody else's screen. A beat is not durable, so it is shed instead: dropped silently, counted for the operator, and never turned into a claim that the client has a hole in its log. The next beat is twenty seconds away.

## Two settings an operator has

WEALD_RELAY_LIVE is on or off. Off refuses every beat and is a posture rather than a failure: a self-hoster who does not want to carry a beat every twenty seconds per connected device turns it off, and a client renders presence as unavailable.

WEALD_RELAY_LIVE_FANOUT is process by default. Single-process fanout is correct in a single-process deployment and in no other, so a relay that has been told it runs more than one instance refuses to start rather than showing every member half the room. A relay that will not start is a page a human reads.

## Unavailable is not offline

A version 1 relay, a relay with the ephemeral path off, and a workspace synced through git all produce the same client state: presence unavailable. That is a different sentence from everybody being offline and only one of them is ever true. A client that rendered the first as the second would tell every member that their whole team had gone home.
