Reos real-time text (RTT) protocol
On this page
Version 1, 2026-08-30. Published at https://www.reos.ai/accessibility/rtt-protocol.
This document is the specification by which real-time text is exchanged between Reos clients. It exists to be read, not only to be implemented: EN 301 549 clause 6.2.3 option (c) requires RTT in a non-PSTN, non-SIP system to use "a relevant and applicable common specification for RTT exchange that is published and available for the environments in which they will be operating", and requires that specification to "include a method for indicating loss or corruption of characters". A format nobody outside the codebase can read satisfies neither half. This does.
It also pre-satisfies EN 301 549 V4.1.0 clause 6.2.10 option (a), which asks only that "the ICT's documentation shall contain information on the main specifications by which RTT is implemented".
1. What this is, in one line
Reos carries an ITU-T T.140 text stream between call participants, framed per IETF RFC 8865 (T.140 Real-Time Text Conversation over WebRTC Data Channels), over the room's existing signalling WebSocket.
2. Scope
- In scope: human-led calls, between participants holding a room token
with the role
hostorguest. - Out of scope: AI-moderated interviews, which have their own text path. See section 9.
- Not affected: the message-based chat panel, which is a separate feature and a separate clause. RTT is additive.
3. Relationship to RFC 8865, stated exactly
RFC 8865 specifies a T.140 stream on a WebRTC data channel negotiated in
SDP with dcmap/dcsa attributes (RFC 8864). Reos conforms to the parts of
it that describe the stream and departs from the part that describes the
channel. Both halves are stated here rather than glossed, because an
overstated conformance claim is worse than a documented deviation.
Conformed
| Requirement | Where RFC 8865 states it | How Reos meets it |
|---|---|---|
Subprotocol identifier t140 | Section 3 | Every frame Reos emits, in either direction, carries "subprotocol": "t140". The field labels the stream rather than gating it: the room admits a block on its sequence number and its text and does not require the field to be present. |
| Reliable transmission | Section 3 ("Transmission reliability: reliable") | The room signalling WebSocket runs over TCP/TLS. |
| In-order transmission | Section 3 ("Transmission order: in-order"; "T.140 requires the transport channel to provide transmission of real-time text without duplication and in original order") | Same transport; the room additionally rejects any block whose sequence number does not advance, so a replay cannot rewrite a buffer. |
| No redundancy | Section 5.2 ("T.140 data channels do not use redundant transmission of text; this is because the T.140 data channel achieves robust transmission by using the 'reliable' mode") | No redundancy is implemented. RFC 4103's redundancy scheme is deliberately not reimplemented. |
| Loss indication on re-establishment | Section 5.4 ("a receiver SHOULD indicate to the user that a channel has been reestablished and text might have been lost. This MAY be done by inserting the missing text markers [T140ad1]") | Both re-establishment and in-flight loss insert U+FFFD. See section 6. |
| No retransmission after re-establishment | Section 5.4 ("the sending endpoint MUST NOT retransmit T140blocks") | On reconnect the sender freezes the part of its composition the room actually received - never the whole field, see below - so delivered characters are not sent twice. Whatever was typed since the last frame was never transmitted and still goes out. The receiver's marker stands in for what was lost. A participant joining mid-line receives no history. |
| Characters per second | Section 4.2.1 ("If no 'fmtp' attribute with a 'cps' attribute parameter is included, the default value of 30 applies [RFC4103]") | 30 cps sustained, with a ten-second burst allowance. |
Not conformed, and why
- No SDP negotiation. There is no
m=applicationsection, noa=dcmap/a=dcsa, and no SCTP data channel. Reos media runs through a selective forwarding unit whose API exposes no data-channel primitive that maps onto RFC 8864 negotiation, and the room already has a reliable, ordered, authenticated per-participant transport with an identity model. Framing T.140 onto it costs nothing and gains the specification's guarantees; building an SDP-negotiated data channel would gain only the SDP. - No gateway behaviour (RFC 8865 section 6). Reos interoperates with no RFC 4103 endpoint, because it offers no PSTN dial-in and no SIP bridge. If either is ever added, EN 301 549 clause 6.2.3 options (a) and (b) become applicable and this document stops being sufficient on its own.
4. Character set (the Reos T.140 profile)
Text is Unicode. The following T.140 codes are transmitted and acted on:
| Code point | Name | Meaning |
|---|---|---|
| U+0008 | BACKSPACE | Erase the last character entered by this source. |
| U+2028 | LINE SEPARATOR | New line. CR, LF and CR LF are accepted on input and normalised to this. |
| U+FFFD | REPLACEMENT CHARACTER | Missing text marker (T.140 Addendum 1). Inserted by a receiver; nothing in Reos sends one. It is not filtered on input, so a participant who pastes one transmits a character every receiver renders as missing text and refuses to erase. |
The following T.140 codes are not implemented: U+0007 BEL, the INT
interrupt sequence, and the ISO 6429 graphic-rendition strings (SGR, SOS, ST,
ESC). What is stripped is the character that introduces them - U+001B ESC,
U+0098 SOS, U+009C ST - rather than the sequence as a unit, so nothing acts on
one; the printable payload of a 7-bit sequence survives as ordinary text, and
ESC [ 1 m arrives as [1m. U+FEFF (the T.140 idle/keep-alive character) is
accepted and discarded; Reos does not need a keep-alive because the room
WebSocket has its own.
All other C0 and C1 control characters are stripped.
Erasure unit. BACKSPACE erases one user-perceived character, not one UTF-16 code unit: an astral code point, a base character with its combining marks, a ZWJ emoji sequence, or a regional-indicator pair each come off whole. Hangul jamo and Indic conjuncts are approximated - erasing one of those may take two BACKSPACEs.
5. Frames
5.1 Client to room
{ "type": "rtt", "subprotocol": "t140", "seq": 1, "text": "<T140block>" }
seqstarts at 1 on every WebSocket open and increases by exactly one per frame from that sender. The room rejects a frame whoseseqdoes not advance.textis a T140block: at most 1000 characters, non-empty after normalisation. A larger edit (a paste) is split across consecutive frames by the sender.- Frames are paced at one per 300 ms. EN 301 549 clause 6.2.4 permits up to 500 ms of buffering (NOTE 2) and prefers 300 ms or less (NOTE 3).
5.2 Room to client
{
"type": "rtt",
"participantId": "…",
"role": "host",
"speakerName": "Sarah (Interviewer)",
"channelId": 1756470000000,
"seq": 1,
"text": "<T140block>",
"subprotocol": "t140",
"direction": "received",
"timestampMs": 1756470000000
}
- Relayed to every participant except the sender. A sender renders its own text locally; echoing it would put a network round trip between a keystroke and the sender seeing it.
speakerNameis the same label the room builds for live captions and the persisted transcript, so a person is named identically everywhere.channelIdidentifies the sender's channel and changes on every re-establishment. It is seeded from the wall clock so it increases even across a restart of the room server.
5.3 Channel state
{
"type": "rtt_channel",
"participantId": "…",
"role": "guest",
"speakerName": "Alex (Participant)",
"channelId": 1756470000000,
"state": "open"
}
Sent only for a participant who has already sent RTT in this room. open
means their channel was re-established; closed means they left and their
open line will never be completed.
5.4 Errors
Rejections come back to the sender as the room's ordinary error frame with
one of these codes: rtt_forbidden (role may not send), rtt_invalid
(malformed frame), rtt_rate_limited (retryable), rtt_sequence (the
sequence number did not advance).
The frame carries seq, so the sender can say which of its lines failed.
A rejection is a round trip and the participant will usually have typed on by
the time it lands; a pane that could only blame its newest line would put
"not delivered" on a sentence that arrived while saying nothing about the one
that did not - a false claim in both directions at once. seq is absent in two
cases: rtt_forbidden, where there is no frame behind it because the role may
not send at all, and an rtt_invalid whose frame carried no usable sequence
number of its own to quote back. In both the sender's most recent line is the
one marked. A
rejection whose seq matches nothing we still hold - the line aged out, or
the channel was re-established while it was in flight - marks nothing, because
a re-established channel already carries the missing text marker.
A rate-limited block is genuinely lost: the sender's seq has already
advanced, so the next accepted block leaves a gap and every receiver marks
it. That is the intended behaviour - the alternative is a receiver holding a
sentence that silently lost its middle.
A rejection is shown to the sender, not only logged. The sending pane
renders your own text the moment you compose it, because clause 6.2.4 gives
the sender's own view a 500 ms budget that a network round trip would eat -
which means every local line is a claim about delivery rather than a fact. On
a surface whose whole purpose is that someone who cannot speak is being heard,
an uncorrected false claim is the worst available failure. So an rtt_* error
reaches the pane, and the line it belonged to says "not delivered" in text and
is announced once to assistive technology, outside the quiet window that
governs ordinary line announcements.
Nothing is retried automatically. The client holds the same character budget the room admits blocks with (section 7), so a well-behaved sender cannot outrun it; a rejection that survives that means the two ends disagree about the rules, and re-sending the same characters under a fresh sequence number would duplicate them in a receiver's buffer on the one occasion the original did arrive.
5.5 What survives a reconnect, and what must not
Section 5.4 of RFC 8865 forbids retransmission, and there are two opposite ways to get it wrong. They look identical from the sender's chair, because in both cases text is sitting on their screen that the other party does not have:
- Re-sending delivered text. Clearing the transmitted-text baseline is what causes this: the next diff runs from empty and re-sends the whole composition the participant is still looking at, so the receiver gets the same words twice in a marker-prefixed line.
- Dropping undelivered text. Freezing the whole composition causes this: whatever was typed since the last frame never left the tab, and freezing it excludes it from every frame after the break.
The line is therefore drawn at what the room actually received, not at what the field holds - and "received" has to mean the frame reached the socket, not that the sender decided to send one. The room's signalling transport drops a message when the socket is not open, which is correct for everything re-derivable (a ping reasserts itself, a user update is re-sent on reopen) and wrong for a per-character baseline. So the transport reports whether it wrote, the sender is handed that answer before any state moves, and a refused frame advances nothing: not the sequence number, not the baseline, not the queue, not the rate budget. The same characters are offered again on the next tick, and nothing renders them as sent in the meantime.
With that in place:
| At the moment the socket drops | What happens on the new channel |
|---|---|
| Characters already framed and sent | Frozen. Never sent again. |
| Characters typed since the last frame | Still owed. Sent. |
| A line the participant committed with Enter, not yet framed | Still owed. Sent, line separator and all - minus its erasures, which were aimed at a line this channel cannot reach and would land on the receiver's marker. |
| Delivered characters the participant has since erased | Neither re-sent nor erased - the old line is beyond this channel's reach, and a BACKSPACE aimed at it would land on the receiver's missing text marker instead. |
| Characters written to a socket that had already failed | Indicated, not recovered. See below. |
The one case this cannot resolve, and why that is the specification's answer
A synchronous WebSocket write is not a delivery receipt. A frame handed to a half-open connection is gone, and nothing at this layer can tell it apart from one that arrived: the failure is only observable later, when the socket closes. So on a re-establishment there is always a boundary of text whose fate is genuinely unknown.
There are only two ways to close that, and RFC 8865 rules out the one that would work. Re-sending the unconfirmed tail is a retransmission, which section 5.4 forbids in terms - "the sending endpoint MUST NOT retransmit T140blocks" - because a receiver that did get the original has no way to deduplicate it and would show the words twice, inside a sentence, with no indication which reading is right. An acknowledgement scheme would move the boundary rather than remove it (an ack can be lost as easily as a block) and would still leave retransmission as the only remedy, so it buys nothing it is allowed to spend.
The specification's answer to an ambiguity it cannot remove is to declare it, which is what the missing text marker is for and why clause 6.2.3 option (c) demands one. Reos therefore does the thing that is permitted, on both ends:
- every receiver marks the break, per section 5.4;
- and so does the sender's own pane. A sending client reopens its own channel through the same path a peer's break goes through, so a participant who reconnects mid-sentence is told their text may not have arrived rather than left looking at it and assuming. A reconnect nobody was typing through shows nothing, because nothing was in flight.
That is the whole of the residue: a bounded, disclosed uncertainty at each re-establishment, in place of either silent loss or duplicated words.
6. Loss indication
This is the clause 6.2.3(c) requirement, and it fires in three cases.
- Sequence gap. A receiver that sees
seqjump inserts U+FFFD at the point of the jump before applying the new block. - Channel re-establishment. A receiver that sees a new
channelIdfor a sender it already holds text from closes that sender's open line and starts a new one beginning with U+FFFD. - Channel closed. A receiver told a sender's channel closed finishes that sender's open line rather than leaving it unterminated. No marker - nothing was lost, the sentence simply ended.
The marker cannot be erased. BACKSPACE stops at it, on both ends: the sender clamps erasures that would reach below what the current channel has sent, and a receiver refuses to erase a marker whatever arrives. Text before a marker is therefore unreachable, exactly as text before a completed line is. Without that rule a correction queued just before a reconnect - which begins with erasures aimed at the old line - would delete the disclosure and leave the loss, which is worse than the loss it was disclosing, and it is the one outcome clause 6.2.3 option (c) exists to prevent. The receiver-side half also holds against a peer whose client does not clamp.
The marker is not left as a bare glyph. The pane renders it as a labelled inline run reading "text may be missing", and the screen-reader announcement substitutes the same words, because U+FFFD read aloud is either silence or "replacement character".
7. Rate limits
Per sender, enforced by the room:
| Limit | Value |
|---|---|
| Sustained characters per second | 30 (RFC 8865 section 4.2.1 default) |
| Burst allowance | 300 characters |
| Frames per second | 20 |
| Characters per frame | 1000 |
Both ends hold the same bucket. The room's admission control and the sending client are two users of one shared implementation of the character budget rather than two implementations of one rule. The sender sizes each block against its live budget, so a paste larger than the burst is spread across frames at the rate the room admits instead of going out whole and being refused - which is what used to happen, invisibly, to exactly the participant this feature exists for.
The client's copy is always the conservative one: both buckets start full when the channel opens, both refill on elapsed time, and the room sees a block strictly later than the client sent it, so the room has refilled at least as much as the client had when it decided. Neither side reads the other's clock.
The characters-per-frame ceiling is therefore a validation backstop rather than the binding constraint - the budget is always the smaller of the two.
8. Retention
RTT text is ephemeral. The room validates a block, relays it, and forgets it. Nothing is written to server-side storage, to the session transcript, to the recording, or to the database. A receiver keeps roughly the last 200 lines in the browser tab - only completed lines are dropped, so a line still being typed can hold the buffer a little above that - and closing the tab ends it.
9. AI-moderated interviews
RTT as specified here is not offered in AI-moderated interviews. That is a scoping decision with a known counter-argument, recorded so it is not mistaken for an analysis:
- Clause 6.2.1.1's precondition is "Where ICT is in a mode that provides a means for two-way voice communication". It contains no human-to-human qualifier, and an AI interview is plainly two-way voice.
- EN 301 549 V4.1.0 contemplates non-human parties explicitly (clause 6.2.2.2 NOTE: "who or what is currently communicating"; clause 6.2.9 NOTE 1: "a non-human process").
- Arguing the AI surface out of clause 6.2 lands it in clause 6.4 (alternatives to voice-based services, which names interactive voice response facilities), whose requirement - a complete path through the interview without hearing or speech - is larger, not smaller.
The AI interview surface is therefore not covered by this specification.
10. References
- ETSI EN 301 549 V3.2.1 (2021-03), clauses 6.2.1.1, 6.2.1.2, 6.2.2.1, 6.2.2.2, 6.2.2.3, 6.2.3, 6.2.4, and Annex C.
- ETSI EN 301 549 V4.1.0 (final draft, 2026-06), clause 6.2.10.
- IETF RFC 8865, T.140 Real-Time Text Conversation over WebRTC Data Channels.
- IETF RFC 4103, RTP Payload for Text Conversation.
- IETF RFC 9071, RTP-Mixer Formatting of Multiparty Real-Time Text (its section 3 tabulates the T.140 control codes).
- ITU-T Recommendation T.140 and its Addendum 1.




