Skip to main content

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:

URLNotes
ws://127.0.0.1:42031/wsPrimary path
ws://127.0.0.1:42031/api/wsAlias (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.

info

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 /health and /api/health endpoints 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"
}
FieldTypeMeaning
typestringAlways "event" for streamed output
event.typestringAlways "output.data"
event.terminalIdstringWhich terminal produced this chunk
event.data.contentstringThe raw output chunk (see note below)
timestampstringServer 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.

note

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.data frames for all terminals as soon as the socket is open, whether or not you send a subscribe message. Treat subscribe as a handshake acknowledgement (and a spot to hold a subscriptionId), 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"
}
}
FieldRequiredMeaning
payload.actionyesMust be "terminal:input"
payload.terminalIdyesTarget terminal id
payload.datayesBytes 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.

tip

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