The transport binding was the last piece missing from agent text streams, and it did not need a new QUIC implementation — `:quic` already had the whole hard part. What it needed was entering that stack at the right layer. `nestsClient` speaks WebTransport: HTTP/3, Extended CONNECT, QPACK, SETTINGS. Its `WebTransportSession` abstraction begins above all of that. Marmot's binding is raw QUIC — it negotiates its own ALPN (`marmot.quic_broker.v1` / `marmot.quic_stream.v1`) and writes frames straight onto QUIC streams, with no HTTP/3 anywhere in it. So this reuses everything below that line — connection, TLS 1.3, ALPN negotiation, stream multiplexing, loss recovery, the UDP socket — and none of the WebTransport wrapper. The codecs are in quartz next to the rest of agent-text-stream, because they are pure bytes and that is where the conformance risk lives: the control envelope with its literal 21-byte protocol string and its trailing-byte rejection, the uint32 frame codec with both the broker's blind cap and a policy-aware one, `quic://` candidate parsing down to ignoring everything after the authority and never sending an IP literal as SNI, and the first record's stream id pinning the rest. `:marmotQuic` is the connection layer, mirroring how `:nestsClient` sits on `:quic`. A publisher claims a room on a uni stream, a subscriber reads the fan-out on a bidi one, and an endpoint that does not take our ALPN is reported as unusable so the caller moves to the next candidate rather than waiting on records that never come. Verified against MDK's own `marmot-quic-broker`, which is the only way to know a wire format is right: our publisher and subscriber meet inside the reference broker, the records come back, open under the group-derived key and fold to the publisher's transcript hash, and the broker keeps rooms apart. Opt in with -DmarmotQuicBroker=host:port; the cases skip visibly without one, so an ordinary test run needs no broker. Still not wired at the app layer: nothing yet mints a kind-1200 start, picks a candidate, or renders a live preview, so `send` (0xF2D2) and `fanout` (0xF2D4) stay unadvertised. The direct path has no start-payload candidate format in v1 and is unimplemented. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
marmotQuic
Marmot's raw QUIC transport binding for agent text stream previews
(transports/quic.md), on top of the repo's own pure-Kotlin :quic stack.
Why this is not nestsClient's WebTransport
Both features move bytes over :quic, but they enter it at different layers.
nestsClient speaks WebTransport: HTTP/3, an Extended CONNECT handshake, a
:protocol pseudo-header, QPACK, SETTINGS negotiation. Its
WebTransportSession abstraction starts above all of that.
Marmot's binding is raw QUIC. It negotiates its own ALPN —
marmot.quic_broker.v1 for the broker path, marmot.quic_stream.v1 for the
direct one — and writes frames straight onto QUIC streams. There is no HTTP/3
in it at all, so WebTransportSession is the wrong shape.
What both share is everything below that line, which is the hard part and is already built: the QUIC connection, TLS 1.3, ALPN negotiation, stream multiplexing, loss recovery and the UDP socket.
Shape
- A publisher opens a client-initiated unidirectional stream, writes a
publishcontrol envelope, then record frames. - A subscriber opens a client-initiated bidirectional stream, writes a
subscribecontrol envelope, and reads the fan-out on the return direction.
A broker rejects the wrong pairing. Both roles frame everything the same way:
uint32 frame_len || bytes, the control envelope first and then each
AgentTextStreamRecordV1.
The codecs — control envelope, frame reader/writer with both caps, quic://
candidate parsing — live in quartz next to the rest of agent-text-stream,
because they are pure bytes and belong with the feature. This module is only
the connection.
The broker sees nothing
Records are encrypted under a key derived from the group's MLS exporter. A
broker holds no key and learns only the routing pair
(stream_id, start_event_id) plus ciphertext. It is an untrusted forwarder,
and a candidate that points somewhere hostile still cannot forge a record.
Interop tests
MarmotQuicBrokerInteropTest drives our publisher and subscriber through
MDK's own reference broker. Start it from an MDK checkout:
cargo build --release --bin marmot-quic-broker
./target/release/marmot-quic-broker --bind 127.0.0.1:4450 --json
then:
./gradlew :marmotQuic:jvmTest -DmarmotQuicBroker=127.0.0.1:4450
Without the property the cases skip visibly, so an ordinary ./gradlew test
never needs a broker on the machine.
Not done
- Nothing in the app yet mints a kind-1200 start payload, chooses a broker candidate, or renders a live preview — this is the transport, not the feature wiring.
- The direct path (
marmot.quic_stream.v1) is unimplemented. v1 defines no start-payload candidate format for it, so it is only reachable with an endpoint known out of band.