AI endpoints tend to pick a transport per feature. Chat streams. “Generate a report” is a JSON POST because it returns one thing. The slow one becomes a job you poll. Now the client has three parsers and three error paths, a button can’t show progress the way chat does, and moving a producer from fast to slow means changing every caller.

So there’s one transport. Every request to /assistant (a turn, a command or a make) answers with the same server-sent event stream.

  POST /assistant { intent: "turn" | "command" | "make", ... }
  ◀── 200 text/event-stream

  event: thinking   { content: "Checking 3 questions", metadata: { stepId, status: "running" } }
  event: payload    { content: "{…}", metadata: { kind: "query", v: 1, transient: true } }    ← something a step ran
  event: content    { content: "Most late orders ship from…" }
  event: payload    { content: "{…}", metadata: { kind: "report", v: 1 } }                    ← the deliverable
  event: error      { content: "…", metadata: { status: 409, code: "catalog-required" } }

Every event carries the same frame: id, requestId, type, content, and metadata with the conversationId stamped on (the server mints one if the request didn’t send it). A payload’s body is the JSON in content, and its envelope { kind, v, id?, transient? } is the metadata.

A make is a promise over the stream

A button that wants one thing doesn’t need a different endpoint. It sends intent: "make" and reads the stream until the payload it asked for:

export async function make<T>(kind: string, input: unknown, { signal }: { signal?: AbortSignal } = {}): Promise<T> {
  const response = await fetch("/assistant", {
    method: "POST",
    headers: { "Content-Type": "application/json", Accept: "text/event-stream" },
    body: JSON.stringify({ protocolVersion: 3, intent: "make", make: { kind, input } }),
    signal,
  });
  for await (const event of readEvents(response)) {
    if (event.type === "payload" && event.metadata.kind === kind && !event.metadata.transient)
      return JSON.parse(event.content);                     // the terminal payload; stop reading
    if (event.type === "interrupt" || event.type === "error")
      throw new MakeError(event.type, event.content);
  }
  throw new MakeError("error", `No ${kind} came back`);
}

The server side of a make is a contract: progress, then exactly one terminal payload, or an interrupt, or an error, then the stream ends. No model routing, no agent turn.

Rules

  • Status lives in the event. Once the stream opens, the HTTP status is already 200. A failure after that is an error event with metadata.status and a code the client can act on: 409 to resend with more, 404 for a tool you can’t use, 400 for bad input. Only an unauthenticated or malformed request fails before the stream opens.
  • Interrupt isn’t error. interrupt means the assistant needs something from the person, like a question it can’t answer without them, or a request it can’t fill yet. error means something broke. A surface shows them differently.
  • Transient payloads are for checking, not keeping. A query a step ran comes as a payload of the same kind marked transient. The person can open it from its step, but a make never resolves with it and history never stores it.
  • Unknown events are ignored. Adding an event type or a metadata field never breaks an old client. The wire version (protocolVersion) changes only when a request’s or event’s meaning does. Each payload kind carries its own v.
  • One deadline. The request budget is the longest producer’s, so a slow make is never cut short by a fast turn’s timeout. Each producer still keeps its own shorter deadline inside it.

What it gives up

No HTTP caching, since a stream isn’t a cacheable GET, so producers cache on the server instead. And no resume: a dropped connection loses the request, because the client holds the conversation and the server holds no stream to rejoin. Both have been cheaper than three transports.

Why it works

There’s one parser, one error path and one place to read events, whatever sent the request. A button gets live progress and openable steps for free, because a make streams the same events a chat turn does. A producer that gets slower changes nothing on the client. And Ask, Act, Make stays one door all the way down: one URL, one way in, one way back.

How this fits the larger system: The UI is the engine.