Streaming progress
Watch a build over server-sent events, and what resuming can and cannot recover.
GET /v1/builds/{id}/events is a text/event-stream that runs for the life of the build.
const events = new EventSource(`https://feedbuilder.usestring.ai/v1/builds/${id}/events`);
events.addEventListener("build.state", (e) => render(JSON.parse(e.data)));
events.addEventListener("build.gate.raised", (e) => promptOperator(JSON.parse(e.data)));
events.addEventListener("build.error", (e) => fail(JSON.parse(e.data)));Generated clients will not handle this endpoint
OpenAPI has no way to describe a stream of framed events, so every generator produces a method that buffers the
whole response or times out. Use your language's SSE client here — EventSource, httpx + httpx-sse,
launchdarkly/eventsource — and the generated client for the other nine operations.
Frame shape
id: <build version>
event: <name>
data: <one line of JSON>
data is always a single line: the payload is JSON-encoded, which escapes every newline.
Event names
| Event | data |
|---|---|
build.state | A full build object. The first frame is always this one. |
build.progress | {id, version, status, progress?, pending_gate_id?} |
build.activity | One activity item. |
build.gate.raised | One gate — answer it at POST /v1/builds/{id}/gates/{gate_id}. |
build.error | The ordinary error envelope. |
Register one handler per kind and ignore a kind you do not know — new event types are added without warning.
build.error is how a stream reports a failure after its headers are already written; without it you could not tell
a finished build from a dropped connection.
An unknown or foreign build id is a real 404, not a 200 that ends immediately.
Heartbeat
A : keep-alive comment goes out every 15 seconds. Comments are ignorable by the protocol, so no client needs to
handle it.
Resuming: Last-Event-ID suppresses, it does not replay
id: carries the build's version. Frames with no version of their own — a gate raise, an activity item — are
stamped with the last one seen, so Last-Event-ID stays monotonic across every frame type.
A gap in the stream cannot be replayed
Nothing resends the frames you missed. A resubscribe gets a fresh full-state snapshot — status, pending gate,
gate history, messages, transcript — and Last-Event-ID only drops frames you already have.
Progress ticks from while you were disconnected are gone. Gate raises and activity items survive, because the snapshot re-carries them.
The practical consequence: when a build.state frame arrives, treat it as truth and never accumulate state by
applying build.progress deltas across a reconnect.
A caught-up resubscribe gets no frames at all
Suppression is Last-Event-ID >= the build's current version, so if nothing has happened since you disconnected,
the snapshot is suppressed too and the stream opens silent — only : keep-alive comments until the build moves.
Last-Event-ID sent | build at version 465 |
|---|---|
1 | build.state |
464 | build.state |
465 | nothing |
99999 | nothing |
So do not block on build.state before rendering — a client that waits for it hangs on a quiet build. Render
from your existing state, or from GET /v1/builds/{id}, and let the frame update you when it comes.
Note the last row: a version higher than the build's suppresses until the build passes it. Only ever send back an
id: this stream gave you.
If you would rather poll
GET /v1/builds/{id} returns the same build object the first frame carries. version increases monotonically, so
you can cheaply tell whether anything changed. Polling is the right choice for a batch integration that checks in
every few minutes; the stream is the right choice for a UI.