public-transit: The Relay That Starts Itself

A TypeScript bridge for local agents that streams output to the browser, auto-hosts on demand, and treats abort, undo, and redo as core behavior.

11 min read • View on GitHub • More from aidenybai

A wide transit hub shows a backend terminal on the left, a relay station in the center, and a browser window on the right. The scene explains how a relay can appear when a handler asks for it and carry a live stream without manual setup.
The relay behaves like infrastructure that materializes only when work is about to move.
Key Takeaways

The clever part of public-transit is not that it uses WebSockets. It is that the relay can be missing, and the first handler that needs it can make it exist. That flips infrastructure from something you prewire into something that appears because work is about to happen.

Why this exists

If you have ever built a browser UI for a local process, you know the tax. You wire a socket server, thread session IDs through every layer, add cleanup for dead tabs, and then write a second path for cancellation. The repository reads like an attempt to cut that tax down to one contract.

The CLI intentionally starts the server as a detached background process and exits immediately. The issue is there's no way to stop it gracefully.

tt-a1i, GitHub Contributor · react-grab issue #57

That complaint is the right problem to look at. public-transit answers it by binding startup and teardown to the same lifecycle, so the relay is not a separate service you babysit. It is part of the handler's connection story.

Three actors, one protocol

The cleanest architectural choice is the shared protocol file. protocol.ts is the source of truth, and every other piece speaks its language. That matters because the system is not just a socket pipe. It is a conversation between a handler, a relay, and a browser client.

The protocol keeps the three actors in one conversation, while the connection helper makes relay startup a side effect.

That split also keeps the transport layer boring in the best way. The server can multiplex by agentId and sessionId, the browser can subscribe to the right stream, and the handler can stay focused on work. The relay becomes a coordinator, not a place where domain logic leaks.

Streaming is the default

The use of AsyncGenerator is the most telling design decision. A handler does not return a one-shot response. It emits a sequence of messages, which fits agentic work much better than a request-response wrapper ever could.

import { connectRelay } from "public-transit";

const handler = {
  id: "summarizer",
  async *run(input, ctx) {
    yield { type: "status", message: "starting" };
    const output = await summarize(input, { signal: ctx.signal });
    yield { type: "result", value: output };
  }
};

await connectRelay(handler);

That shape is useful on both sides of the wire. The browser can consume chunks as they arrive, and the backend can keep emitting status, partial progress, or completion without inventing a new protocol for each state. The result feels less like polling and more like watching a live process unfold.

Abort, undo, redo are part of the design

This is where the project stops feeling like a thin relay and starts feeling like a coordination layer. The server keeps track of active sessions, routes control messages back to the handler, and preserves enough state for the browser to ask for more than just stop or continue. Undo and redo are interesting because they make history a first-class part of the session model.

A close-up control desk shows three brass levers on a white tabletop. One cuts a live wire, one rolls a stamped ledger page backward, and one sends the same page forward again, which explains abort, undo, and redo as session-level controls.
Session control is treated like machinery, not scattered UI state.

The implementation details back that up. The connection helper can probe the default port, clear a zombie process if needed, and bring up the relay without asking the caller to manage a separate server process. It also makes the handshake explicit with a relay token, which keeps the public meeting point from becoming an open door.

What this replaces

ConcernManual WebSocket stackpublic-transit
Server startupYou write the server, port management, and lifecycle code yourself.The handler connection can start the relay when it is missing.
Message shapeMessages are usually ad hoc and local to one app.The shared protocol keeps server, client, and handler aligned.
StreamingYou often wrap responses in custom callbacks or buffers.Handlers stream with `AsyncGenerator`s by default.
Session controlAbort, undo, and redo need extra wiring and state.Control is modeled as part of the session protocol.
CleanupZombie ports and stale sockets are easy to leak.Connection logic can clear conflicts and unregister cleanly.

The win here is compression, not magic. You still have sockets, state, and lifecycle edges. You just have fewer places where those concerns can drift apart, which is exactly what you want when the backend process is local, ephemeral, or controlled by an agent.

Where it fits, and where it does not

public-transit looks like a focused utility, not a broad platform, and that is a strength. It fits best when a browser needs to steer a local agent, a CLI, or a long-running task with live feedback. It is not trying to become a general pub/sub bus or a multi-tenant realtime backend.

That narrowness makes the repository feel mature in the places that matter. The code is split cleanly across protocol, server, client, and connection layers, and the choices are opinionated enough to be useful without becoming sprawling. If you need a browser-facing control plane that behaves like infrastructure rather than glue, this is the right kind of small.