freeq voice and video calls are built from two independent layers:
TAGMSGs; the server echoes call state back to the channel. This isKeeping them apart means a call is just metadata on a normal channel:
any IRC client sees the TAGMSGs, and a client with no AV support is
unaffected. This page is the reference for both layers. To build an
agent on top of them, see Build a Voice & Video Agent.
+freeq.at/av-* tags¶All call signaling is IRCv3 TAGMSGs sent to a channel. A client opens,
joins, or leaves a call by sending message tags; the server validates the
action and broadcasts an av-state TAGMSG to every channel member.
| Tag | Value | Meaning |
|---|---|---|
+freeq.at/av-start |
(empty) | Open a new call in this channel. |
+freeq.at/av-join |
(empty) | Join the call named by av-id. |
+freeq.at/av-leave |
(empty) | Leave the call named by av-id. |
+freeq.at/av-instance |
instance id | This client's per-device id (see §3). |
+freeq.at/av-id |
session id | The call to join/leave (not used on av-start). |
+freeq.at/av-title |
text | Optional human-readable call title (on av-start). |
The server answers every accepted action with one av-state TAGMSG:
| Tag | Value | Meaning |
|---|---|---|
+freeq.at/av-state |
started / joined / left / ended |
What changed. |
+freeq.at/av-id |
session id | The call. The MoQ broadcast-path prefix (§3). |
+freeq.at/av-actor |
nick | Who did it, when the server includes it. |
+freeq.at/av-participants |
integer | Live participant count after the change. |
+freeq.at/av-title |
text | The call title, when set. |
av-state=ended fires when the last participant leaves.
freeq_sdk::av builds these tag maps and parses the replies, so an agent
never hand-assembles a HashMap:
use freeq_sdk::av::{self, AvAction, parse_av_state};
// Sending: ClientHandle has the three convenience methods.
handle.av_start("#standup", &av::new_av_instance(), Some("Daily standup")).await?;
handle.av_join("#standup", &session_id, &instance).await?;
handle.av_leave("#standup", &session_id, &instance).await?;
// Receiving: apply parse_av_state to every TAGMSG you get.
if let Some(state) = parse_av_state(&tags) {
match state.action {
AvAction::Started => { /* a call opened — decide whether to join */ }
AvAction::Joined => { /* someone joined state.session_id */ }
AvAction::Left => {}
AvAction::Ended => { /* tear down */ }
}
}
parse_av_state returns None for any TAGMSG that isn't an
av-state broadcast, so it is safe to call on every incoming tag event.
Alice Server Bob
│ TAGMSG av-start ───────▶│ │
│ │── av-state=started ─────▶│ (broadcast
│◀──── av-state=started ───│ │ to channel)
│ │ │
│ │◀──── TAGMSG av-join ─────│
│◀──── av-state=joined ────│──── av-state=joined ────▶│
│ │ │
│ ══ media flows over MoQ between Alice and Bob ══ │
│ │ │
│ │◀──── TAGMSG av-leave ────│
│◀──── av-state=left ──────│──── av-state=left ──────▶│
│ TAGMSG av-leave ───────▶│ │
│◀──── av-state=ended ─────│── av-state=ended ───────▶│
av-start with a fresh instance id. Theav-state=started carrying av-id=<session id>. The starter is theav-join.av-join with that av-id and their ownav-state=joined.av-leave produces av-state=left; the last leaveav-state=ended.A blind av-start is rejected when the channel already has a live call.
An agent that wants a call running should probe first:
GET /api/v1/channels/{channel}/sessions
→ { "active": { "id": "<session id>", "state": "Active", ... } }
If active is non-null and Active, av-join it; otherwise av-start.
This avoids the race where two clients both try to open the same call.
Every participant publishes exactly one MoQ broadcast. Its path is:
{session_id}/{nick}~{instance}
session_id — the call, from av-id. Shared by every broadcast in"{session_id}/" prefix and ignores stale broadcasts from other calls.nick — the participant's display name.instance — a per-device id: 8 lowercase hex charactersfreeq_sdk::av::new_av_instance()). The same identity joining fromnick, not the full path.freeq-av has helpers for both directions:
use freeq_av::{broadcast_path, path_nick};
let path = broadcast_path("01HXYZ", "eliza", "0a1b2c3d"); // "01HXYZ/eliza~0a1b2c3d"
let nick = path_nick(&path); // "eliza"
Media rides MoQ — Media over QUIC — through an SFU (selective
forwarding unit). The SFU endpoint is /av/moq on the freeq server host;
QUIC is the low-latency path, with a WebSocket fallback for environments
where QUIC can't establish.
A subscriber decodes each remote broadcast to PCM locally. Publishing a
continuous audio stream (silence included) keeps subscribers attached,
so there is no join latency when a participant actually starts talking.
The freeq-av crate packages this whole plane — connecting, publishing,
watching the announce stream, and decoding every participant — behind one
AvSession. See the agent tutorial.
+freeq.at/av-* tags sees a normal channel.TAGMSG history.session_id.ATPROTO-CHALLENGE