The transport was there and the codecs were there; nothing joined them to
a group. Now `amy marmot stream start|send|watch|finish` does: a hidden
kind:1200 anchors the stream over MLS, records ride raw QUIC through a
broker, and a kind:9 closes it carrying the transcript a receiver checks
its own fold against.
`AgentTextStreamSubscriber` is the receive discipline the binding spells
out, and it matters because a preview that quietly diverges is worse than
no preview: `seq` accepted at most once and never folded out of order, a
replayed record (which a broker WILL send from the start of its replay
window on reconnect) discarded silently and never stream-fatal, a gap
that cannot be backfilled marking the preview unverifiable because the
transcript hash can no longer complete. Only TextDelta and Checkpoint
reach the answer text — progress and status are chrome the spec forbids
from ever reaching notifications, indexes or automation input.
The start payload also grew the tags it was missing: `stream-type`,
`final-kind` and the optional `parent`, plus the rule that a final
payload whose kind disagrees with `final-kind` is ignored.
Verified in both directions against MDK in harness tests 18 and 19: `wn
stream verify` confirms our transcript from our own kind:1200 + kind:9,
and our subscriber folds MDK's stream to a transcript hash identical to
the one `wn stream send` computed. That equality is the key schedule, key
context, AEAD, framing and transcript construction all agreeing with an
implementation that is not ours. 19 of 19 harness tests pass, twice.
Two defects only that exercise could have found:
- The epoch belongs to the stream, not to the clock. The record key
context binds mls_epoch, and both sides were resolving it as "the
group's current epoch" at each command, so a commit landing between
the start and the send put them on different keys and produced an
empty preview. The epoch that DELIVERED the kind:1200 is the
stream's; it is persisted with the message now and read back by
publisher and receiver alike.
- close() dropped the tail of a stream. enqueue only fills the send
buffer, so tearing the connection down before the driver flushed it
lost records silently — the publisher had already counted them. QUIC
ACKs a FIN only once everything ahead of it arrived, so finish() now
waits for finAcked. This is exactly why the test passed alone and
failed inside a full run.
The `send` (0xF2D2) and `fanout` (0xF2D4) role capabilities stay
unadvertised: a role is a promise to the whole group, and only the CLI
originates a stream so far.
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.
Using it
amy marmot stream drives the whole feature; the harness's tests 18 and 19
run it in both directions against MDK.
amy marmot stream start GID --broker quic://127.0.0.1:4450
amy marmot stream send GID --stream-id … --start-event-id … --broker … "hello"
amy marmot stream watch GID --stream-id …
amy marmot stream finish GID --stream-id … --transcript-hash … --chunk-count N "hello"
Not done
- The GUIs do not originate or render a stream yet, which is why the
send(0xF2D2) andfanout(0xF2D4) role capabilities stay unadvertised — a role is a promise to the whole group. - 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.