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.
- public-transit turns relay startup into a side effect of connection, which removes the setup tax that usually sits between a backend agent and a browser UI.
- The repo's real contract is a shared streaming protocol, and `AsyncGenerator`s make partial output and completion feel like one flow instead of separate code paths.
- Abort, undo, and redo are session primitives here, so control is part of the transport instead of an afterthought in the frontend.
- The library compresses WebSocket plumbing into a narrow TypeScript API, but its scope stays deliberately small and local.
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.
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.
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.
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
| Concern | Manual WebSocket stack | public-transit |
|---|---|---|
| Server startup | You write the server, port management, and lifecycle code yourself. | The handler connection can start the relay when it is missing. |
| Message shape | Messages are usually ad hoc and local to one app. | The shared protocol keeps server, client, and handler aligned. |
| Streaming | You often wrap responses in custom callbacks or buffers. | Handlers stream with `AsyncGenerator`s by default. |
| Session control | Abort, undo, and redo need extra wiring and state. | Control is modeled as part of the session protocol. |
| Cleanup | Zombie 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.