vibe-live docs

How federation works

What the code does today. The decisions behind it are in docs/design/cross-instance-viewing.md and backlog item F28, F49, F51 and F62; the cases that prove it between two real instances are in docs/testing/federation-e2e.md.

Federation here is three separate things, and keeping them apart is most of understanding it:

What crosses Who decides Gated by the peer row?
Following a follow, and "went live" announcements back each person no
Peering streams on the dashboard, and people writing in each other's chat the two admins yes -- that is what the row is
Signing in from elsewhere a person, to watch the visited instance only a per-host block

Following: ActivityPub, open to anyone

Every channel is an ActivityPub actor (/ap/users/{slug}), found through WebFinger. Anybody on the fediverse -- Mastodon included -- can follow it, and a person here can follow somebody on another instance (POST /api/v1/me/following). When a channel goes live, a Create of a Note is delivered to its followers' inboxes.

Following is never gated by the peer policy, deliberately: a follow carries no content, and anybody could go to the other instance and follow from there. The owner, 2026-09-28, on the peer split: follows should not "follow", since "people could go to other instances themselves".

Peering: two directions per capability, both ends consenting

A peer row (peer_policies, internal/store/peers.go) is one remote host and what this instance allows with it. Since migrations 36 and 37 every capability has two directions, and each direction needs both admins:

Capability This instance's column Means In force when
show streams allow_streams their live streams appear on our dashboard we show and they share
share streams share_streams ours appear on theirs we share and they show
accept chat allow_chat their signed-in people may write in our chat we accept and they share
share chat share_chat ours may write in theirs we share and they accept

So an instance can be a source without being a mirror, or the reverse. StreamsEffective and ChatEffective are the inbound gates this instance enforces; SharedStreamsEffective and SharedChatEffective are the outbound directions, which the PEER enforces -- here they are only reported, so an admin can see why nothing crosses.

Learning their half. ActivityPub has no way to say "I allow you", so GET /ap/peer-policy?host=<asker> answers it, about the asker only (never the whole list). A task asks every peer every 30 minutes, and an answer older than 6 hours counts as no -- a peer that withdrew permission and went quiet does not keep its old yes. Saving a peer asks it again at once, even while an ask is in flight: the ask's own "again in 30 minutes" never postpones a save's request (Store.ScheduleFollowUp).

Telling them it changed (F75). Saving also books a nudge-peer task: POST /ap/peer-nudge, empty, signed by the instance actor (peernudge.go). The peer checks the signature is a peer's own instance key (verifyPeerInstance, as for the peer list), rate-limits by address first, and books its ask of us now -- so a change is read on both sides within seconds rather than at the other's next rotation. Booking either wakes the task runner (tasks.Runner.Wake), so neither waits for its 30-second tick; measured on the e2e pair, about a second end to end. The nudge carries no policy: the answer still comes from our /ap/peer-policy, fetched with Cache-Control: no-cache past any shared cache. A peer that is down or older (404) is not retried; it learns at its rotation as before.

{"instance":"a.example","peer":"b.example","known":true,
 "streams":true,  "streams_show":false,
 "chat":true,     "chat_accept":true}

known says whether the answerer has a row for the asker at all.

streams and chat are what the answerer offers the asker -- the half every version has read those fields as -- and streams_show and chat_accept are what it takes from the asker. An answer without the second pair comes from an instance older than 0.3.0 / 0.4.0, whose single flag meant both directions, and is read that way. So instances of any version keep reading each other correctly.

Requests (F64). An instance that adds yours first asks this endpoint about itself every half hour, and has no row here: that ask is its request. Because the endpoint is public, an ask is only recorded (peer_requests) and a background task asks that host back; only a host that says it knows us (known) -- or, older than that field, allows us something -- is shown to an admin. Adding it is the ordinary add; dismissing hides it; a day without asking drops it.

sequenceDiagram
  participant B as b.example (added a.example first)
  participant A as a.example
  participant M as a.example's admin
  B->>A: GET /ap/peer-policy?host=b.example
  A-->>B: known false, nothing allowed
  Note over A: no row for b.example, so the ask is recorded (peer_requests)
  A->>B: background task: GET /ap/peer-policy?host=a.example
  B-->>A: known true
  A->>M: listed under Asking to connect
  M->>A: Add (everything off, then ticks what it allows)
  B->>A: the next ask, within half an hour
  A-->>B: known true, and what a.example now allows

Setting it: the admin page (a box per direction, each with a reading of "ok", "one-way", "unreachable" or "off"), vibe-live peer set <host> from a shell. --streams and --chat set both directions; --show-streams, --share-streams, --accept-chat, --share-chat set one. vibe-live peer list --json gives each direction as set and in force, for a script.

Streams from a peer, on this dashboard

remotelive.go: every peer whose streams are in force here is asked for its public GET /api/v1/live every 30 seconds, and an announcement from an actor we follow books an immediate look. What comes back is stored, and the gate is applied when the dashboard is rendered (PermittedRemoteChannels), not when rows are written -- so withdrawing a permission hides what is already stored at once. A row not re-confirmed within two minutes is hidden too.

Watching one (remoteplayback.go): the page asks /api/v1/remote/{peer}/{channel}/playback, and this instance asks the peer's own playback endpoint server to server -- only for a stream it already lists. The peer's engine can differ from ours (a LiveKit token only the peer can mint), which is why the descriptor is fetched rather than built; see engines.md.

Nothing in it is passed through. none and whep are the app's own: a built-in peer's WHEP endpoint is rebuilt from the peer's host, never taken from its answer. Any other transport is vetted by the engine that owns it (engineapi.Transport.CheckPeer, handed to the server as Config.PeerTransports): OvenMediaEngine's keeps only its name, since the page builds that URL itself, and LiveKit's accepts a ws or wss server and a bounded token. A transport no registered engine owns, or one its engine refuses, is a 502, and the page keeps its default.

Counts. A viewer here plays the peer's media from the peer, so the peer's media proxy counts them with everybody else, and the peer's number is the only one that includes them. Its /api/v1/live carries viewers and followers per channel (the dashboard socket's own counts), this instance stores them with the card on each refresh, and the remote card and page show them -- at most half a minute old.

Relaying a stream to a peer (F86a, the origin's side)

relay.go: a peer can ask this instance to push one of its channels to the peer's own RTMP ingest, so the peer's viewers watch from the peer. That moves this instance's upload from one stream per remote viewer to one per peer, and keeps the peer's viewers' addresses off this origin (docs/design/renditions-and-federated-copies.md).

The receiving side

relayasks.go: for a peer whose row has relay on (migration 57, peer_policies.relay_streams), every live channel of theirs shown here is asked for every 30 seconds, to rtmp://<media host>:<rtmp port>/app/<key>.

Chat on a stream from a peer

remotechat.go: the room stays on the stream's own instance -- the origin -- and the viewer's instance relays it. Nobody has a session on an instance they did not sign in to; a person interacts through their own.

sequenceDiagram
  participant V as bob's browser
  participant B as b.example (bob's instance)
  participant A as a.example (the stream's)
  V->>B: WS /ws/remote/a.example/{channel}
  B-->>V: {"type":"relay","origin":"a.example","can_write":true}
  B->>A: WS /ws/channels/{channel}?as_subject=..&as_username=bob&as_slug=bob<br/>signed with bob's ActivityPub key
  A->>B: fetch bob's actor document, verify the signature
  A-->>B: history, then live frames
  B-->>V: frames, re-expressed for a reader on b (chat.RelayFrame)
  V->>B: {"body": "hi"}
  B->>A: {"body": "hi"} (only if bob may write)
  Note over A: stored as bob, host b.example; shown on a as @bob@b.example

Names. A message records where its author lives (chat_messages.author_host, migration 41). On the wire a person from another instance has author_host and author_url (their profile THERE) and no author_slug, never both -- so an older client renders a plain name instead of linking a stranger's slug to a local profile. Clients show a local person as alice and anybody else as @bob@a.example, linking home. The relay re-expresses the origin's frames for a reader on its side: the origin's people become remote, this instance's own become local again, and mentions resolved on the origin link there.

Moderation is the origin's (F67, docs/design/chat-moderation.md). A relayed chatter is a visitor row there, the same row a visit signs into, so a ban on them holds by either path. The relay passes the frames moderation adds that a remote panel needs:

It never passes role: a relayed socket moderates nothing, because moderating happens signed in at the room's own instance. An older relay drops deleted, and its viewers keep seeing the message until they reload.

discovery.go (F66; the design is docs/design/federation-discovery.md). Two more directions on a peer row, shaped like streams and chat: propagate (we name this peer to our other peers) and let_propagate (it may name us), each in force only when the other side agrees -- so a peer is named only with its own consent. Both cross in the peer-policy answer as propagate and propagate_allowed, always stated, so an older peer names nobody.

sequenceDiagram
  participant B as b.example
  participant A as a.example
  B->>A: GET /ap/peer-policy?host=b.example
  A-->>B: ... "propagate": true, "propagate_allowed": true
  B->>A: GET /ap/peer-list, signed by b.example/ap/instance
  A->>B: fetch /ap/instance, verify the signature
  A-->>B: {"peers": ["c.example"]} (c agreed to be named by a)
  Note over B: c.example becomes a suggestion, named by a.example

Tested against fake peers at the server level; the e2e stack has two instances and discovery needs a third, so no e2e case covers it yet.

A relayed stream's audience, told back to its origin

peeraudience.go (F87 step 3, docs/design/stats-access.md). A viewer on B who watches A's channel directly pulls the media from A, so A's own figures count them. A relayed stream (above) is the exception: B plays it from its own origin and A sees one push. So:

Rate limits on what peers send (F88, ratelimitpeers.go). The signed peer endpoints (/ap/peer-list, peer-policy, peer-nudge, relay, peer-audience) count under the lookup limiter, the inbox under inbox, the sign-in exchange under federation. Each runs before the signature is verified, so the instance is only what the keyId claims: an admin's override for one peer is counted per claimed host AND address, and refusals are counted by that claim (other for a host that is not a peer).

Signing in from elsewhere, to watch

federationlogin.go: somebody with an account on instance A signs in on B to watch, follow and -- where chat is in force from A -- write in B's chat. It is OAuth's authorization code with PKCE between two instances that share no secret: B sends the browser to A, A asks the person, A redirects back with a one-time code bound to B's host, and B exchanges it server to server with the verifier. The session B opens is for a row that can never own a channel: you can only stream on your own instance.

Sign-in is open by default, the opposite of streams and chat: signing in to watch needs no agreement between admins. An admin can close it for one host (block_signin) or for everyone (the instance switch).

The identity A sends also carries the person's own theme (V6), when they chose one: a theme's id and dark or light. Every instance has the same themes, so B keeps it on the visitor's row when it has that theme and ignores it otherwise -- their look follows them, and nothing else does.

Where to change what

To change Start at
what a peer row holds, or how "in force" is computed internal/store/peers.go, then a migration
what we publish about a peer, or how we read theirs internal/server/peerpolicy.go (peerPolicyDoc)
which remote streams appear remotelive.go (PermittedRemoteChannels)
who may write in chat from elsewhere federationlogin.go (peerChatAllowed)
who a relayed viewer is, and whether they may write remotechat.go (relayIdentity, relayedAuthor)
how a relayed room's names read here internal/chat/relay.go (RelayFrame)
who is named to whom, and what becomes a suggestion discovery.go, internal/store/discovery.go
the ActivityPub protocol itself internal/activitypub, inbox.go

Diagrams drawn by Mermaid (MIT license).