A tiny streaming web chat

For a complete desktop, browser, or headless experience, use Interpreter Workstation instead. It already has a composer, approvals, sessions, and richer tool output. This page is for learning how to put your own small local UI in front of raw Open Interpreter (OIX), not for reproducing Workstation.

The browser talks to a loopback-only Node bridge; the bridge launches interpreter app-server --listen stdio://. Your provider login/configuration stays with the server process, never in JavaScript sent to the browser. Sign in with interpreter first, then run these two files from a disposable project directory. Node 20+ is needed; no npm packages are required.

The bridge sends JSON-RPC initialize, waits for its matching response, then sends initialized. It calls thread/start once and turn/start for each prompt. During that turn, item/agentMessage/delta carries text and turn/completed carries the final status (including failures). The bridge matches response IDs and thread/turn IDs before streaming text to the page.

Save the following two snippets as bridge.mjs and index.html in the same directory, run node bridge.mjs, and open http://127.0.0.1:8787/.

Complete two-file local example

bridge.mjs

import { createServer } from "node:http";
import { spawn } from "node:child_process";
import { readFileSync } from "node:fs";
import { createInterface } from "node:readline";

const port = 8787;
const origin = `http://127.0.0.1:${port}`;
const child = spawn(process.env.OIX_BINARY ?? "interpreter",
  ["app-server", "--listen", "stdio://"], { stdio: ["pipe", "pipe", "inherit"] });
const pending = new Map();
let nextId = 0, threadId, active;
const write = message => child.stdin.write(JSON.stringify(message) + "\n");
function call(method, params) {
  const id = ++nextId;
  return new Promise((resolve, reject) => {
    pending.set(id, { resolve, reject });
    write({ id, method, params });
  });
}
function emit(a, event) {
  if (a.closed) return;
  const line = JSON.stringify(event) + "\n";
  // Reserve space for an explicit error; this is a per-turn transport limit,
  // not a limit on how many turns the app server can keep in its thread.
  if (a.bytes + Buffer.byteLength(line) > 65536 - 256) {
    fail(a, "Turn exceeded the 64 KiB browser stream limit; interrupted");
    return;
  }
  a.bytes += Buffer.byteLength(line);
  if (!a.res.write(line)) fail(a, "Browser too slow; turn interrupted");
}
function fail(a, reason) {
  if (a.closed) return;
  a.res.end(JSON.stringify({ type: "error", fatal: true, text: reason }) + "\n");
  interrupt(a);
}
function interrupt(a) {
  a.closed = true;
  if (a.turnId && !a.interrupted) {
    a.interrupted = true;
    call("turn/interrupt", { threadId, turnId: a.turnId }).catch(() => {});
  }
}
function notification(message) {
  const { method, params: p } = message;
  const a = active;
  if (!a || p?.threadId !== threadId) return;
  if (a.closed) {
    if (method === "turn/completed" && (p.turnId ?? p.turn?.id) === a.turnId)
      active = undefined;
    return;
  }
  // Notifications may arrive before the turn/start *response*; queue only a
  // bounded number and bytes until its ID is known, then filter by both IDs.
  if (!a.turnId) {
    const size = Buffer.byteLength(JSON.stringify(message));
    if (a.queue.length < 32 && a.queueBytes + size <= 16384) {
      a.queue.push(message); a.queueBytes += size;
    }
    else fail(a, "Too many early events; turn interrupted");
    return;
  }
  if ((p.turnId ?? p.turn?.id) !== a.turnId) return;
  if (method === "item/agentMessage/delta") emit(a, { type: "text", text: p.delta });
  if (method === "error") emit(a, { type: "error", text: p.error.message });
  if (method === "turn/completed") {
    emit(a, { type: "done", status: p.turn.status,
      error: p.turn.error?.message ?? null });
    if (!a.closed) a.res.end();
    a.closed = true;
    active = undefined;
  }
}
createInterface({ input: child.stdout }).on("line", line => {
  let m;
  try { m = JSON.parse(line); } catch { return; }
  if (m.id !== undefined && m.method) {
    // This teaching example has no approval UI. Explicitly decline requests;
    // never accept one just because a browser is connected.
    const approval = ["item/commandExecution/requestApproval",
      "item/fileChange/requestApproval"].includes(m.method);
    write(approval ? { id: m.id, result: { decision: "decline" } }
      : { id: m.id, error: { code: -32601, message: "No UI for this request" } });
    if (active) emit(active, { type: "error", text: `Declined: ${m.method}` });
  } else if (m.id !== undefined) {
    const p = pending.get(m.id);
    if (p) { pending.delete(m.id); m.error ? p.reject(Error(m.error.message)) : p.resolve(m.result); }
  } else if (m.method) notification(m);
});
child.on("error", error => {
  console.error(`Cannot launch interpreter: ${error.message}`);
  for (const p of pending.values()) p.reject(error);
  pending.clear();
});
child.stdin.on("error", () => {}); // a failed child may close stdin first
child.on("exit", () => {
  for (const p of pending.values()) p.reject(Error("app server exited"));
  pending.clear();
  if (active) fail(active, "app server exited");
  process.exitCode = 1;
});

// The app server requires the initialize response before initialized, then
// thread/start before turn/start. Keep a single read-only thread in this demo.
try {
  await call("initialize", { clientInfo: { name: "tiny-web-chat", version: "1.0" } });
  write({ method: "initialized" });
  threadId = (await call("thread/start", {
    cwd: process.cwd(), sandbox: "read-only", approvalPolicy: "on-request"
  })).thread.id;
} catch (error) {
  console.error(`App server start failed: ${error.message}`);
  process.exit(1);
}

const server = createServer(async (req, res) => {
  if (req.headers.host !== `127.0.0.1:${port}` ||
      (req.method === "POST" && req.headers.origin !== origin)) {
    res.writeHead(403).end(); return;
  }
  if (req.method === "GET" && req.url === "/") {
    res.writeHead(200, { "Content-Type": "text/html; charset=utf-8",
      "X-Content-Type-Options": "nosniff" });
    res.end(readFileSync(new URL("./index.html", import.meta.url))); return;
  }
  if (req.method !== "POST" || req.url !== "/chat") {
    res.writeHead(404).end(); return;
  }
  if (active) { res.writeHead(409).end("Wait for the current turn"); return; }
  if (req.headers["content-type"] !== "text/plain" ||
      Number(req.headers["content-length"] || 0) > 4096) {
    res.writeHead(413).end(); return;
  }
  const chunks = [];
  let size = 0;
  try {
    for await (const chunk of req) {
      size += chunk.length;
      if (size > 4096) throw Error("Prompt too long");
      chunks.push(chunk);
    }
  } catch { res.writeHead(413).end(); return; }
  const prompt = Buffer.concat(chunks).toString("utf8");
  if (!prompt.trim()) { res.writeHead(400).end(); return; }
  // Both bodies may have started while idle; only one may reserve the turn.
  if (active) { res.writeHead(409).end("Wait for the current turn"); return; }
  res.writeHead(200, { "Content-Type": "application/x-ndjson; charset=utf-8",
    "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" });
  const a = active = { res, queue: [], queueBytes: 0, bytes: 0, closed: false };
  res.on("close", () => { if (active === a && !a.closed) interrupt(a); });
  try {
    a.turnId = (await call("turn/start", {
      threadId, input: [{ type: "text", text: prompt }]
    })).turn.id;
    if (a.closed) interrupt(a);
    for (const event of a.queue) notification(event);
    a.queue.length = 0; a.queueBytes = 0;
  } catch (error) {
    fail(a, `Turn start failed: ${error.message}`);
    active = undefined;
  }
});
server.requestTimeout = 30_000;
server.listen(port, "127.0.0.1", () => console.log(origin));

index.html

<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Local Open Interpreter chat</title>
<style>
  body { max-width: 48rem; margin: 3rem auto; padding: 0 1rem;
    font: 1rem/1.5 system-ui; color: #20242c; background: #fafafa }
  #chat { height: min(60vh, 32rem); overflow-y: auto; overflow-anchor: none;
    padding: 1rem; background: white; border: 1px solid #ddd; border-radius: .8rem }
  #chat > div { white-space: pre-wrap; overflow-wrap: anywhere; margin-bottom: 1rem }
  .state { color: #58606c; font-size: .85rem }
  form { display: flex; gap: .6rem; margin-top: 1rem }
  textarea { flex: 1; min-height: 4rem; font: inherit }
</style>
<h1>Local chat</h1><div id="history" class="state" aria-live="polite"></div>
<div id="chat" role="log" aria-live="off"></div>
<form><textarea aria-label="Your message" maxlength="4096" required></textarea>
  <button>Send</button></form>
<script>
const form = document.querySelector("form"), box = document.querySelector("#chat");
const history = document.querySelector("#history");
const input = form.querySelector("textarea"), button = form.querySelector("button");
const nearBottom = () => box.scrollHeight - box.scrollTop - box.clientHeight < 48;
function newTurn(prompt) {
  const pinned = nearBottom(), row = document.createElement("div");
  const user = document.createElement("div"), answer = document.createElement("div");
  const state = document.createElement("div");
  user.textContent = `You: ${prompt}`;
  answer.textContent = "Assistant: ";
  state.className = "state";
  state.textContent = "Streaming…";
  state.setAttribute("aria-live", "polite");
  row.append(user, answer, state);
  box.append(row);
  while (box.children.length > 8) {
    const oldest = box.firstElementChild, before = box.scrollHeight;
    const oldTop = box.scrollTop;
    oldest.remove(); // visible window only; the app-server thread is unchanged
    history.textContent = "Earlier visible turns trimmed (conversation stays on server).";
    if (!pinned) box.scrollTop = Math.max(0, oldTop + box.scrollHeight - before);
  }
  if (pinned) box.scrollTop = box.scrollHeight;
  return { answer, state };
}
form.addEventListener("submit", async event => {
  event.preventDefault(); button.disabled = true;
  const prompt = input.value, { answer, state } = newTurn(prompt);
  let tail = "", trimmed = false, label = "Streaming…", frame = 0, reader;
  function paint() {
    if (frame) cancelAnimationFrame(frame);
    frame = 0;
    const pinned = nearBottom();
    answer.textContent = `Assistant: ${trimmed ? "[Earlier answer text trimmed]\n" : ""}${tail}`;
    state.textContent = label;
    if (pinned) box.scrollTop = box.scrollHeight; // instant, only if following
  }
  const schedule = () => { if (!frame) frame = requestAnimationFrame(paint); };
  try {
    const response = await fetch("/chat", { method: "POST",
      headers: { "Content-Type": "text/plain" }, body: prompt });
    if (!response.ok) throw Error(`HTTP ${response.status}`);
    input.value = "";
    reader = response.body.getReader();
    const decoder = new TextDecoder();
    let buffer = "", finished = false, fatal = false, notice = "";
    while (true) {
      const { value, done } = await reader.read();
      if (done) break;
      buffer += decoder.decode(value, { stream: true });
      if (buffer.length > 70000) throw Error("Stream line too large");
      let newline;
      while ((newline = buffer.indexOf("\n")) >= 0) {
        const event = JSON.parse(buffer.slice(0, newline));
        buffer = buffer.slice(newline + 1);
        if (event.type === "text") {
          if (tail.length + event.text.length > 12000) trimmed = true;
          tail = (tail + event.text).slice(-12000); // rolling visible tail
        } else if (event.type === "error") {
          if (event.fatal) { finished = true; fatal = true; }
          else notice = String(event.text).slice(0, 120);
          label = `${event.fatal ? "Interrupted" : "Notice"}: ${String(event.text).slice(0, 300)}`;
        } else if (event.type === "done") {
          finished = true;
          if (!fatal) label = event.status === "completed" ?
            `Complete${notice ? ` (notice: ${notice})` : ""}` :
            `${event.status}: ${String(event.error ?? "no details").slice(0, 300)}`;
        }
        schedule(); // at most one DOM update per animation frame
      }
    }
    if (decoder.decode() || buffer || !finished)
      throw Error("Stream closed without a complete final event");
  } catch (error) {
    label = `Error: ${String(error.message).slice(0, 300)}`;
    if (reader) await reader.cancel().catch(() => {});
  } finally { paint(); button.disabled = false; }
});
</script>

The same server home and working directory determine the account, provider and workspace. This deliberately supports one local user and one turn at a time; a second POST gets 409. It is not identity isolation: every local tab shares this thread. Closing the response interrupts its turn; until the app server reports turn/completed, another turn stays blocked. Each streamed delta and completion is matched to the active thread and turn; JSON-RPC responses are matched by request ID. Messages are rendered as text, never HTML. This demo declines command/file approvals and rejects other server requests; use Workstation or implement a real review UI if approvals are needed. It does not display tool events, images, full conversation history, or multiple users. The page keeps only the latest eight visible turns and the last 12,000 characters of the current answer; older visible text leaves the top as the session grows. This is a small rolling display, not Workstation's virtualized, retrievable history. If the text being read is trimmed, it cannot be preserved; the same applies when the current answer's rolling tail replaces text being read. Both trims are visibly marked. Otherwise the page keeps the reader's position and follows new output only when already near the bottom. The app server retains the thread's conversation independently of this display. Browser writes are batched to one animation frame, not animated scrolls.

The bridge's 64 KiB per-turn browser-stream cap is an intentional safety limit, not a total session limit. Hitting it interrupts the turn and visibly reports an error; an unexpected EOF is never shown as success. A full client would implement backpressure, pagination, and cancellation. This tiny process starts a fresh thread on each launch; it does not implement persisted-session resume or rebuild previous messages in the UI.

Security boundary: keep the bridge on loopback. Do not forward its port or expose the raw app-server socket publicly. The Host/Origin checks and small input/output bounds above are educational safeguards, not remote authentication, CSRF protection for every deployment, rate limiting, permission isolation, or production backpressure handling. For remote or multi-user hosting you must add authenticated transport, explicit user/session isolation, authorization, resource limits, and proper approval handling; do not put tokens, OAuth files, or provider keys into the page. See App Server, Sandbox & approvals, and Workstation's public source for full-client design.

A tiny streaming web chat | Open Interpreter