WebSocket
TermFlow's local API exposes a WebSocket endpoint that streams live terminal output and accepts input, on the same port as the REST API. It is what the terminal-monitor dashboard uses to mirror running terminals in real time, and it is available to any tool you build.
Endpoint
The WebSocket lives on the API server port (prod 42031, dev 42051 — the same port as http://127.0.0.1:42031/api). Two paths route to the identical handler:
| URL | Notes |
|---|---|
ws://127.0.0.1:42031/ws | Primary path |
ws://127.0.0.1:42031/api/ws | Alias (used by terminal-monitor) |
Both behave the same — pick whichever your client library prefers. If you changed the API port in Settings → Connections, use that port here too.
The WebSocket carries UI-facing terminals. Output is broadcast to every connected client, so a single socket sees the live byte stream of all open terminals, tagged by terminalId. Use the Terminals API to discover ids first.
Auth
Auth follows the same rule as the rest of the local API: enforced only when the server is exposed on the LAN.
- Default (localhost only, bound to
127.0.0.1): no token required. The socket is reachable only from your own machine, which is the trust boundary. - When "Expose on local network" is on: the token must ride as a
?token=query parameter — browsers cannot set custom headers on a WebSocket handshake, so the Bearer-header scheme used by REST does not apply here.
ws://<lan-ip>:42031/ws?token=<authToken>
The <authToken> is the value shown (masked, with reveal/copy/Rotate) in Settings → Connections — the same network.authToken used everywhere else. A wrong or missing token is rejected with an HTTP 401 on the upgrade, and the socket never opens.
An honest note: The
/healthand/api/healthendpoints stay open even when exposed, but the WebSocket does not — an exposed server always requires the query-param token to upgrade.
For the full auth model, see Local API and auth and the API overview.
The welcome frame
Immediately after the socket opens, the server pushes one welcome frame. Receiving it confirms the connection is live:
{
"id": "welcome",
"success": true,
"data": { "version": "0.1.0", "mode": "tauri" }
}
Server → client: the output stream
For every chunk of output any terminal produces, the server sends an event frame:
{
"type": "event",
"event": {
"type": "output.data",
"terminalId": "term-abc123",
"data": { "content": "\u001b[32mhello\u001b[0m\r\n" }
},
"timestamp": "2026-07-04T12:34:56.789Z"
}
| Field | Type | Meaning |
|---|---|---|
type | string | Always "event" for streamed output |
event.type | string | Always "output.data" |
event.terminalId | string | Which terminal produced this chunk |
event.data.content | string | The raw output chunk (see note below) |
timestamp | string | Server send time, RFC 3339 (UTC) |
content is the raw PTY output, decoded as UTF-8 (lossily) — it still contains ANSI escape sequences (colors, cursor moves, screen clears) exactly as a full-screen TUI emits them. Feed it into a terminal emulator such as xterm.js to render it faithfully; do not treat it as plain text. Chunks arrive as the PTY produces them, so a single logical line may span several frames.
Every connected client receives output for every terminal. Filter by event.terminalId on the client if you only care about one.
Client → server: messages
All client messages are JSON text frames. Include a unique id on each one — the server echoes it back on the matching response so you can correlate request and reply. There are three message types.
1. heartbeat
Keeps the connection warm and confirms the server is responsive.
{ "id": "hb-1", "type": "heartbeat" }
Response:
{ "id": "hb-1", "success": true }
2. subscribe
Acknowledges intent to receive events and returns a subscription id.
{ "id": "sub-req-1", "type": "subscribe" }
Response:
{
"id": "sub-req-1",
"success": true,
"data": { "subscriptionId": "sub-9f2c1e4a-..." }
}
An honest note: The output stream is not gated by subscribing — the server begins forwarding
output.dataframes for all terminals as soon as the socket is open, whether or not you send asubscribemessage. Treatsubscribeas a handshake acknowledgement (and a spot to hold asubscriptionId), not as a filter.
3. command with action: "terminal:input"
Writes input bytes to a terminal — the WebSocket equivalent of POST /api/terminals/:id/input. The action lives under payload:
{
"id": "cmd-1",
"type": "command",
"payload": {
"action": "terminal:input",
"terminalId": "term-abc123",
"data": "ls -la\r"
}
}
| Field | Required | Meaning |
|---|---|---|
payload.action | yes | Must be "terminal:input" |
payload.terminalId | yes | Target terminal id |
payload.data | yes | Bytes to write to the PTY (include \r to submit a line) |
Success response:
{ "id": "cmd-1", "success": true }
If the terminal id is unknown (or the write fails), the reply reports the failure — always check success rather than assuming the write landed:
{ "id": "cmd-1", "success": false, "error": "terminal not found" }
Input sent this way is tagged as an external (API) source, the same as REST input — useful to know if you rely on TermFlow's per-agent color-scheme stickiness.
Because data is written verbatim to the PTY, you can also send control bytes as JSON escapes: "\u0003" (Ctrl+C), "\u001b" (Esc), or an arrow-key sequence like "\u001b[A" (Up).
Any other command action — or any message type the server doesn't recognize — is answered with a bare { "id": "...", "success": true } acknowledgement and otherwise ignored.
A full exchange
Minimal example
A tiny Node client that connects, sends a command, and prints output for one terminal:
import WebSocket from "ws";
// On localhost no token is needed. When exposed on the LAN, append
// `?token=<authToken>` from Settings -> Connections.
const ws = new WebSocket("ws://127.0.0.1:42031/ws");
const terminalId = "term-abc123";
ws.on("open", () => {
ws.send(JSON.stringify({ id: "sub-1", type: "subscribe" }));
ws.send(JSON.stringify({
id: "cmd-1",
type: "command",
payload: { action: "terminal:input", terminalId, data: "echo hi\r" },
}));
});
ws.on("message", (raw) => {
const msg = JSON.parse(raw.toString());
if (msg.event?.type === "output.data" && msg.event.terminalId === terminalId) {
process.stdout.write(msg.event.data.content); // raw bytes incl. ANSI
}
});
The REST helper POST /api/auth/token returns a JWT that the auth gate does not check — it is not the WebSocket credential. Authenticate an exposed socket with the config authToken (?token=) from Settings → Connections, as described above.
Next steps
- REST & WebSocket as an alternative — when to reach for this instead of the MCP server.
- Terminal monitor — the companion dashboard that mirrors terminals over this WebSocket.