Stealth previewdocs are early and rough. Please don't share publicly.
New Theory
DocsAPIModels

WebSocket vs HTTP

Why the shipped inference API is a WebSocket stream instead of HTTP POST, what each transport gives up, and what the wire shape says about the product.

View as Markdown

What this page is

A reference for the choice, not an argument for it. The shipped API is WebSocket. Both transports would work at our current call rate. The reason we picked WebSocket is the wire shape, not the latency.

The two transports, at the wire

HTTP is request/response. The client opens a connection, sends one request, reads one response, and the conversation is over. Keep-alive can reuse the TCP and TLS state for the next request — modern clients do this by default — but each call still has its own beginning, middle, and end. The unit is the call.

WebSocket is a persistent bidirectional stream. The client opens one connection, and from then on either side can send framed messages until somebody closes it. The unit is the connection.

Both sit on top of TCP and TLS. TCP isn't a separate axis for this choice.

Why the wire shape matters for NT

The case for WebSocket isn't latency. At our actual call rate it isn't even close — HTTP with keep-alive amortizes the handshake to near zero, and the per-call gateway cost is dominated by inference. The case is about what the wire shape says.

A world model is a bidirectional stream of observation and inference. Sensors flow in, intent flows back, the loop runs until the task ends. That is what the product is. The wire shape developers first encounter is the first thing that trains their mental model of what NT is — before the SDK, before the docs, before the first print(chunk). A POST endpoint trains "inference call": you ask, you get one answer, you're done. A persistent stream trains "ongoing control loop": you join the conversation, it runs, you leave. The two framings build different products on top.

Every API surface developers respect today — Stripe, Anthropic, OpenAI, Linear, Resend, GitHub — is HTTP request/response. That's the right shape when the unit of work is a discrete call. The argument for WebSocket here is not "more familiar." It's that we're not a request/response API. We're a streaming control primitive, and the wire shape should say so.

The catch: a streaming primitive is heavier to call directly than a POST. The async-lifecycle ceremony you see in any raw WebSocket example — connect, send, recv, close codes, cancellation, reconnect — isn't a WebSocket quirk. It's what bidirectional streams look like in async languages, whether the substrate is WebSocket, gRPC bidi, HTTP/2 streams, or WebTransport. The complexity belongs to the primitive, not to any one transport. We either expose it to every caller or we wrap it.

We wrap it. The whole pitch is one line:

robot.run("pick up the cup")

The SDK hides the streaming primitive behind that call. The developer never opens a socket, never writes an async function, never sees a close code. WebSocket isn't what they pay. The SDK is where we get to design well — and the wire shape is what lets the SDK's surface be "ask the model to do a task" instead of "make an inference call and then make another one and another one."

What WebSocket gives you

  • A streaming primitive on the wire. Observation goes up, action chunk comes back, both directions stay open. The transport matches the abstraction the SDK exposes and the framing we use for the product.
  • Connection-as-session lifecycle. One run() is one socket. Open is "start of trial," close is "end of trial." The protocol carries the session boundary; you don't reinvent it on top.
  • A surface for out-of-band signals. The server can push a terminal frame when the run hits its stop condition (max_duration, interrupted); the client can push a stop frame to abort. No polling, no second endpoint.
  • One handshake per session, not per request. At today's ~0.4 Hz call rate this is marginal; with HTTP keep-alive the difference shrinks further. At a future world-model rate (10–30 Hz on the wire) the gateway and TLS overhead per call compounds, and amortizing it across one connection starts to matter.

What WebSocket costs you

  • The SDK is mandatory. There is no useful single-line invocation in shell or in another language. websocat confirms the handshake but won't drive inference, since it doesn't encode msgpack. Any non-Python client has to bring a WebSocket library, a msgpack library, and an async runtime.
  • A learning surface most HTTP APIs don't have. Close codes (4001, 4400, 4503), frame types, reconnect policy, cancellation semantics. The SDK hides all of this; a developer writing a custom client sees it all at once.
  • Standard tooling fits awkwardly. No curl, no Postman recording, no browser dev-tools timeline. Logging and observability stacks built around request/response need translation.
  • The QUIC-era successor is not "WebSocket over QUIC." HTTP/3 swaps the transport under the same semantics — a config change, not a code change. WebSocket doesn't have an equivalent migration. The streaming primitive on QUIC is WebTransport, which is a different API. This is a 12–24 month concern, not today's, but it's worth knowing the direction.

What HTTP would give you

  • curl works. Auth, request body in, response body out, in one shell line. So does Postman, so do browser dev tools, so do every fixture-replay harness and request-recorder in the ecosystem.
  • The SDK is optional. Any language with a standard HTTP client and a msgpack library can call the API. No async runtime, no callback wiring, no WebSocket dependency.
  • The auth pattern matches every other API developers respect. Stripe, Anthropic, OpenAI, Linear, Resend, GitHub — all bearer-token HTTP. The vocabulary is already in the developer's head.
  • Standard status codes. 200, 401, 429, 5xx instead of WebSocket close codes. Every HTTP tool already speaks this surface.
  • HTTP/3 is a config flag. Same semantics, faster transport. No rewrite, no API change.

What HTTP would cost you

  • Request/response is not a streaming primitive. The wire shape trains "inference call." Once the world-model story is the pitch — and it is — the wire shape and the pitch say different things. Closing that gap takes copy, examples, and SDK design that the WebSocket shape just doesn't have to do.
  • Per-call overhead compounds at higher rates. At ~0.4 Hz the per-call TCP, TLS, and gateway costs are dominated by the ~1.6 s inference; at 30 Hz they are not. The mitigations are real (keep-alive, HTTP/2 multiplexing, chunked responses, SSE) and each one adds back complexity on the day it ships. WebSocket pays the complexity once.
  • Out-of-band server-to-client signals need a separate shape. A terminal {stop_reason: "max_duration"} from the model has no obvious home in stateless POST. The options are embed it in the action response or add SSE later. Neither is broken; both are extra design.
  • Latency is implementation-dependent across the board. WebSocket adds ~100 ms in some L7 load-balancer and proxy stacks. On New Theory's serving infrastructure, WebSocket and HTTP share routing, so the gap closes. The transport doesn't determine the number — the deployment does. Worth knowing before you trust a benchmark you read on Twitter.

What stays the same either way

  • Wire encoding. msgpack with the msgpack-numpy ndarray extension.
  • Observation schema. state, images, prompt. Same fields, same shapes, same units, whichever transport carries them.
  • Action shape. A (horizon, action_dim) float32 chunk, per the model's contract. Same row layout.
  • stop_reason vocabulary. max_duration, interrupted. The surface that carries it shifts; the vocabulary holds.
  • Auth. Bearer tokens against the same console key lifecycle.
  • Firehose coercion after the handshake. Missing or wrong-shape fields in steady-state frames are coerced server-side and produce degraded actions, not 4xx responses. The first observation frame is contract-gated: a wrong-shape state or a missing required camera closes 4422 with an error envelope.

The choice is the transport. The contract doesn't move.

What we picked and why

We picked WebSocket. The reason isn't latency or handshake overhead — at our current call rate, HTTP with keep-alive holds up fine on both. The reason is that a streaming connection is the wire shape that matches what we're trying to be. The SDK hides the cost of that choice behind robot.run("pick up the cup"). The developer never opens a socket; they ask the model to do a task.

HTTP isn't broken here. It would work — and it would give you curl and a smaller dependency footprint in exchange. The honest version is that both shapes serve the API contract. The one we shipped is the one we want first contact with the product to look like.

On this page