The first version streamed over SSE. Then something needed the same turn over NATS, and I found out I’d built the framing into the wrong layer.
event: / data: / blank line — I had that threaded through the publisher, through the streamers, through the lot. NATS wants none of it. Every publish is already a discrete message, so all that framing was re-solving a problem that layer doesn’t have. Same story with a WebSocket: every write is already a frame.
Which is when it landed. SSE isn’t a sibling of WebSocket and NATS — it’s a codec that one of the three happens to require, because an HTTP response body is a pipe with no seams and something has to mark where one event stops and the next starts. The other two deliver discrete messages on their own.
Treat it as a third transport and you build three parallel stacks for what is one protocol with one awkward binding. I did exactly that, then went back and pulled the framing down into the binding where it belongs.
essessey — say the letters out loud, that’s the name — is what came out.
The wire model is four lines
type Event struct {
Event EventType `json:"event"`
Data json.RawMessage `json:"data"`
}A name and a JSON payload. A Sink delivers it (Emit(ctx, Event) error), a Source reads it back (Next(ctx) (Event, error), ending with ErrNoMoreEvents). Neither interface knows what framing is — because framing is a property of the binding underneath, not of the event.
Which produces the line that matters: a browser EventSource and a NATS subscriber parse identical JSON. A message published to NATS and a chunk scanned off an SSE byte stream decode into the same Go struct on the far end. Not “equivalent.” The same.
So the SSE binding owns a codec the other two never load:
SSE (io.Writer, http.ResponseWriter) framing: YES — an HTTP body is undelimited
NATS framing: no — every publish is a message
WebSocket framing: no — every write is a frameIn practice that means the swap is one constructor and nothing above it moves:
sink := essessey.NewInMemorySink()
// or sse.NewWriterSink(w) / sse.NewHTTPSink(w) / nats.NewSink(conn, "turn") / ws.NewSink(conn)
// or essessey.NewMultiSink(client, store.SinkFor(streamID)) -- fan out to several
pub := essessey.NewPublisher(ctx, sink)Every line after that stays exactly the fucking same. The publisher, the streamers, the event sequence — none of them have any idea which delivery is on the other end, and no reason to.
Seven event types, and the ugly bookkeeping between them
The protocol is Anthropic-shaped: message_start, content_block_start, content_block_delta, content_block_stop, message_delta, message_stop, and ping. Seven.
What the package actually earns its keep on isn’t the enum, though. It’s the boring shit nobody wants to write twice:
- Which content-block index a tool result belongs to. Trivially correct with one tool call. Wrong in the naive implementation everyone writes first the moment calls run in parallel.
- When a thinking block has to close before the answer starts. Ordering that’s obvious in a diagram and easy to get backwards in code.
- Gluing the stream back together at the other end.
Reassembledrains aSourceinto aParsedStream: accumulated text, tool calls matched to their results by block index, and an ordered timeline of both.
Two streamers turn a chunk-at-a-time answer into correctly indexed blocks — TextStreamer and LineStreamer — plus a ThinkingStreamer for reasoning content.
And a detail I like more than I should: InMemorySink isn’t a test double. It’s what you want whenever a turn has to be fully produced before any of it gets released. Feed its Events() into a SliceSource and you round-trip an entire stream with no transport involved whatsoever.
Zero transport dependencies, and I mean check the go.mod
The NATS binding needs one method. The WebSocket binding needs one method. So that’s what each declares:
// nats.Publisher
type Publisher interface {
Publish(subject string, data []byte) error
}
// ws.Conn
type Conn interface {
WriteJSON(v any) error
}A *nats.Conn and a gorilla *websocket.Conn already satisfy these as-is. You pass in the client you already have, and essessey never imports either SDK. The whole module has four direct dependencies, and not one of them is a transport.
This isn’t purity for its own sake. Importing a real NATS or WebSocket client to support one method drags the entire go mod vendor avalanche into every consumer’s build — including consumers who only ever wanted SSE. One method is not worth a dependency tree.
The sse package, for its part, needs nothing beyond the standard library. And a malformed SSE frame gets warn-logged and skipped rather than killing the stream, because one bad chunk shouldn’t end a conversation.
The codec couldn’t round-trip its own output
This one’s embarrassing and worth writing down. Write a multi-line JSON payload through the SSE sink, read it back through the matching source, and you got the first line. Truncated. Remainder discarded. No error, either end.
The format has no escaping and no length prefix, so a newline inside a value ends the field and a blank line ends the event. The framer was writing whole payloads as one data: field and hoping. A blank line inside a payload forged a second event; a newline in an event type let a single Emit write several events the caller never asked for.
Payloads are framed as one data: field per line now, which is how the format actually represents a multi-line value. And the parser runs the format’s state machine instead of matching the literal string "data: " — which had been quietly wrong in three directions: the space after the colon is optional, so a conformant producer that omitted it yielded nothing at all; multiple data: fields lost everything after the first; and typeless events got dropped as malformed when they’re perfectly legal.
That’s a breaking change to the bytes on the wire, and the source now delivers events the old parser silently binned.
A dropped connection stops being a lost turn
The point of all that spec pedantry was Event.ID. An SSE client remembers the last ID it saw and sends it back as Last-Event-ID on reconnect — so if the ID is on the wire, a dropped connection mid-answer is recoverable instead of a restart. Source exposes LastEventID and Retry, and the sse binding gained FrameRetry and FrameComment for the keep-alive side.
An empty id: is not the same as no id: — it resets the receiver’s resume point, which is why the field is omitempty.
But an ID only helps if the server still has the event. The protocol supplies the mechanism and never the retention, so v0.6 added the other half:
MultiSinkfans oneEmitout to several sinks. Every other sink is terminal, so there was no way to send the same event two places — which is exactly what retention needs: the client and the buffer. It forwards to all of them even when one fails, and returns the joined error, because a full store must not cost the client its stream.EventStorekeeps recent events per stream and reports whether a resume point is known rather than guessing. That distinction earns its keep: replaying from the start duplicates everything the client already saw, replaying from now silently drops the gap, and only the caller can decide which is worse.InMemoryEventStoreis the bounded default — ring buffer for order and eviction, id index for lookup, withAppend,Since,SinkForandClear.
Replay reuses SliceSource and the ordinary Sink, so there’s no second code path to drift away from the live one.
Two things it can’t do for you
Both are called out in the per-binding READMEs, and both will waste an afternoon if you don’t know them.
It does not set Content-Type: text/event-stream. That’s your handler’s job, and a browser fails the connection outright without it. The package writes the bytes; it doesn’t own your response headers.
Event IDs are not carried on the NATS binding at all. Resume is an SSE-shaped idea — the reconnect-with-a-header dance is part of that format’s contract, not something NATS or a WebSocket does for you. So the resume story is real on SSE and simply doesn’t exist on the other two.
It doesn’t talk to a model — that’s the other one’s job
essessey moves a turn to whoever’s waiting for it. Producing that turn belongs to elelem, and elelemstream is the seam between them: it translates elelem’s callbacks — text deltas, reasoning deltas, tool-call starts, tool results — into this block protocol, so an elelem-backed handler emits the full message_start → blocks → message_stop sequence without you hand-rolling the translation.
It’s also the only subpackage that imports elelem. The core, sse, nats and ws stay clean, so someone using a different engine never pulls it in.
That subpackage has its own README, and it exists specifically to explain the block-index arithmetic before someone “simplifies” it. Parallel tool calls wreck the obvious implementation. Read it first.
It doesn’t own your HTTP handler. It doesn’t pick your broker. It moves events, in the order they actually happened, to whatever’s on the other end.
Grab it at github.com/psyb0t/essessey. The framing belongs to the binding, not to the events. Once it lives there, adding a fourth way out is a new sink and nothing else.