Skip to main content

Build an Orchestrator on the REST API

Write a small script that spins up terminals, runs commands in them, reads the output back, and watches a live output stream over WebSocket — all against TermFlow's local API. This is the "bring your own tooling" path: no MCP client, just plain HTTP and WS.

TermFlow runs a local REST + WebSocket API (Axum, Rust) on 127.0.0.1:42031 in the installed app. Anything that speaks HTTP can create terminals, drive them, and stream their output — which is exactly what an orchestrator needs.

Ports used on this page

Prod (installed app): API on 42031. If you run TermFlow from source (tauri dev), the API is on 42051 instead. Both bind to 127.0.0.1 unless you turn on "Expose on local network" in Settings → Connections. This page uses the prod port throughout.

What you'll build

A single script that runs this loop:

  1. Check the API is up (GET /api/health).
  2. Create a terminal (POST /api/terminals).
  3. Open a WebSocket and watch the live output stream.
  4. Run a shell command by writing to the PTY (POST /api/terminals/:id/input).
  5. Read the accumulated, ANSI-stripped output (GET /api/terminals/:id/output).

Auth: on localhost you need none

This is the most important thing to get right before you write any code.

  • In the default localhost-only mode, every endpoint is unauthenticated. That is safe because the server is bound to 127.0.0.1 — only processes on your machine can reach it. Your script sends plain requests with no token.
  • Authentication is enforced only when you turn on "Expose on local network" (Settings → Connections). Then every request needs a bearer token, and the WebSocket needs the token as a query parameter.
  • The real credential is the auth token shown in Settings → Connections (masked, with reveal / copy / Rotate). That is the value you put in Authorization: Bearer <token>.
If you expose TermFlow on the LAN

The moment "Expose on local network" is on, the server binds to 0.0.0.0 and anyone who can reach the port can drive your terminals unless they present the token. Treat the token like a password: keep it out of source control, pass it via an environment variable, and rotate it if it leaks.

GET /health and GET /api/health are always open, even when exposed — handy for a readiness probe that never needs a token.

Prerequisites

  • TermFlow 0.1.0 running (Early access · pre-release).
  • Node.js 22+ (this tutorial uses the built-in fetch and WebSocket globals — no packages to install). The same calls translate directly to Python, Go, or curl.

Step 1 — Confirm the API is up

curl http://127.0.0.1:42031/api/health
{ "status": "ok", "app": "auto-terminal", "instanceId": "…" }

The app field carries the internal identifier auto-terminal; that is expected — it is not a different product.

Step 2 — Create a terminal

curl -X POST http://127.0.0.1:42031/api/terminals \
-H "Content-Type: application/json" \
-d '{ "name": "orchestrator", "cols": 120, "rows": 40 }'

The response gives you the id you'll use for every subsequent call:

{
"id": "…",
"processId": "…",
"name": "orchestrator",
"profile": "…",
"status": "running",
"pid": 12345,
"createdAt": "…",
"mode": "ui",
"tabId": "tb-…"
}
Body fieldTypeDefaultNotes
namestringTerminal-<profile>Tab title
colsnumber80Column count
rowsnumber24Row count
profileId / profilestringdefault profileShell profile id or name; unknown values fall back to the default profile
cwdstringprofile's cwdWorking directory to start in
directionhorizontal | verticalSplit an existing pane instead of a new tab
REST create defaults differ from MCP

POST /api/terminals defaults to 80×24. The MCP create_terminal tool defaults to 120×40. If you care about the geometry (you usually do, because GET .../output reflows against it), pass cols/rows explicitly as above.

Step 3 — Run a command

To run an ordinary shell command, write raw bytes to the PTY and end with a carriage return (\r) to submit:

curl -X POST http://127.0.0.1:42031/api/terminals/<id>/input \
-H "Content-Type: application/json" \
-d '{ "data": "ls -la\r" }'
{ "status": "ok" }

An honest note: the input, size, and resize endpoints return HTTP 200 with an { "error": ... } body when the terminal id does not exist — a back-compat quirk. Always check the response body, not just the status code. A robust orchestrator treats body.error as a failure regardless of the 200.

There is also a higher-level POST /api/terminals/:id/execute (body prompt, plus cliType defaulting to copilot). It wraps your text in a bracketed-paste block and then sends a submit signal tuned per agent (cliType accepts copilot, claude, gemini, chatgpt, default, or custom). Use execute when you are driving an interactive agent CLI that needs a specific submit sequence; use /input when you just want to type into a shell. See Execute and batch for the full submit-pattern details and the batch fan-out endpoints.

Step 4 — Read the output

curl "http://127.0.0.1:42031/api/terminals/<id>/output?lines=50"
{
"totalLines": 42,
"offset": 0,
"raw": "total 24\ndrwxr-xr-x …"
}

The raw field is the rendered screen with ANSI escape codes already stripped — you get clean text, not control sequences. Pagination:

Query paramDefaultMeaning
lines50How many lines to return
offset00 returns the last N lines (most recent); a positive offset paginates forward from that line

Because the PTY is asynchronous, give the command a moment to produce output before you read — or, better, watch the live stream (next step) and poll /output only when you need a clean snapshot.

Step 5 — Subscribe to the live output stream

Open a WebSocket to ws://127.0.0.1:42031/ws (the alias /api/ws also works). On connect the server sends a welcome frame:

{ "id": "welcome", "success": true, "data": { "version": "0.1.0", "mode": "tauri" } }

Live PTY output then arrives as event frames:

{
"type": "event",
"event": {
"type": "output.data",
"terminalId": "…",
"data": { "content": "total 24\r\n…" }
},
"timestamp": "2026-07-04T12:00:00Z"
}

An honest note: the output stream is global — the server forwards output.data frames for every live terminal on that one socket, not just the one you "subscribed" to. Filter by event.terminalId in your client. You can still send a subscribe frame (the server replies with a subscriptionId), but it does not narrow the stream today; treat filtering as your job.

You can also send input over the same socket instead of the REST endpoint, using a command frame:

{ "type": "command", "id": "cmd-1", "payload": { "action": "terminal:input", "terminalId": "…", "data": "pwd\r" } }

Send a periodic { "type": "heartbeat", "id": "hb-1" } to keep the connection lively; the server answers { "id": "hb-1", "success": true }.

The full script

// orchestrator.mjs — Node.js 22+ (built-in fetch + WebSocket)
const BASE = "http://127.0.0.1:42031";
const WS_URL = "ws://127.0.0.1:42031/ws";

// Only set TERMFLOW_TOKEN when TermFlow is exposed on the LAN.
// On localhost it is unauthenticated and this stays empty.
const TOKEN = process.env.TERMFLOW_TOKEN ?? "";
const authHeaders = TOKEN ? { Authorization: `Bearer ${TOKEN}` } : {};

async function api(method, path, body) {
const res = await fetch(`${BASE}${path}`, {
method,
headers: { "Content-Type": "application/json", ...authHeaders },
body: body ? JSON.stringify(body) : undefined,
});
const json = await res.json();
// Note: input/size/resize can return HTTP 200 with an { error } body.
if (json.error) throw new Error(`${path} -> ${json.error}`);
return json;
}

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function main() {
// 1. Readiness (always open, even when exposed)
const health = await api("GET", "/api/health");
console.log("health:", health.status, health.app);

// 2. Create a terminal
const term = await api("POST", "/api/terminals", {
name: "orchestrator",
cols: 120,
rows: 40,
});
console.log("terminal:", term.id);

// 3. Watch the live output stream (filter to our terminal)
const wsUrl = TOKEN ? `${WS_URL}?token=${encodeURIComponent(TOKEN)}` : WS_URL;
const ws = new WebSocket(wsUrl);
ws.addEventListener("message", (ev) => {
const msg = JSON.parse(ev.data);
if (msg.id === "welcome") return;
if (
msg.type === "event" &&
msg.event?.type === "output.data" &&
msg.event.terminalId === term.id
) {
process.stdout.write(msg.event.data.content);
}
});
await new Promise((r) => ws.addEventListener("open", r, { once: true }));

// 4. Run a command by typing into the PTY (\r submits)
await api("POST", `/api/terminals/${term.id}/input`, { data: "ls -la\r" });

// 5. Give it a moment, then read a clean snapshot
await sleep(1500);
const out = await api("GET", `/api/terminals/${term.id}/output?lines=50`);
console.log("\n--- snapshot ---\n" + out.raw);

ws.close();
}

main().catch((e) => {
console.error(e);
process.exit(1);
});

Run it:

node orchestrator.mjs

You'll see the ls output stream in live over the WebSocket, then a clean snapshot pulled from /output.

Scaling up: fan out to many terminals

Once the single-terminal loop works, the batch endpoints let one call drive several terminals at once — create N terminals, collect their ids, then POST /api/terminals/batch/execute with terminalIds[] and a shared prompt. The response is a per-terminal results[] plus a summary of total / succeeded / failed, so a single bad id never blocks the rest. That is the foundation of a real orchestrator; see Execute and batch.

Adding the token (LAN-exposed mode)

If you flip on "Expose on local network," reveal and copy the auth token from Settings → Connections, then:

export TERMFLOW_TOKEN="<paste the token from Settings -> Connections>"
node orchestrator.mjs

The script above already reads TERMFLOW_TOKEN: it adds Authorization: Bearer … to every REST call and appends ?token=… to the WebSocket URL (browsers and WS clients cannot set request headers on the handshake, so the token rides as a query parameter).

An honest note: the token in Settings → Connections is the credential the gate actually checks. TermFlow also exposes a POST /api/auth/token route that mints a JWT, but the auth gate does not verify that JWT — do not build your auth on it. Use the config token.

Next steps

  • WebSocket API — every frame type, the welcome handshake, and the ?token= handshake in detail.
  • Execute and batch — submit patterns per agent CLI and the batch fan-out endpoints for multi-terminal orchestration.