File streaming

Watch a file as it's being written and relay it straight to a browser. The pipeline has three parts, all shown below:

  • A producer inside the sandbox appends output to a file — here, an LLM streaming an HTML page token by token.
  • A tail loop in your client follows the file using the sandbox's event stream: each fs.request write wakes it up, and it reads only the new bytes with a byte offset — no polling.
  • A chunked response forwards each new slice to the browser with Transfer-Encoding: chunked, so the page paints as the model types it.

It's ideal for showing an agent's work live: streaming HTML or Markdown from an LLM, a report that fills in section by section, or a log that grows as a long job runs.

Stream an LLM's output to a file

The producer is any process that appends to a file. This one asks Claude for a self-contained HTML page and writes each streamed token straight to /workspace/poem.html, flushing so a reader sees every chunk immediately:

generate.py
# Runs inside the sandbox. Streams an HTML page from Claude into a file.
import anthropic

client = anthropic.Anthropic()  # reads ANTHROPIC_API_KEY from the sandbox env

with open("/workspace/poem.html", "w") as f, client.messages.stream(
    model="claude-opus-4-8",
    max_tokens=2000,
    messages=[{
        "role": "user",
        "content": "Write a complete, self-contained HTML page with a short poem "
                   "and simple inline CSS. Output only the HTML, no preamble.",
    }],
) as stream:
    for text in stream.text_stream:
        f.write(text)
        f.flush()  # flush each token so the tailing reader sees it right away

The sandbox needs the anthropic package (pip install anthropic, or bake it into your image), the ANTHROPIC_API_KEY in its environment, and egress to api.anthropic.com — all wired up when you create the sandbox below.

i
To stream Markdown instead, change the prompt and the file name to poem.md, then pipe the accumulated text through your Markdown renderer. The tail loop is identical.

Follow the file with write events

Every file operation surfaces on the sandbox's event stream as an fs.request. Watch for a write on your path and read the new bytes on each one — an event-driven tail with no polling. readFile(path, { offset }) returns the file's bytes from offset onward, so tracking how many bytes you've consumed gives you exactly what's new:

index.ts
// Follow `path`, driven by the sandbox's write events. Reads the new bytes each
// time the file is written; ends when `signal` aborts.
async function* tail(sandbox, path, signal) {
  let offset = 0;
  const readNew = async () => {
    const chunk = await sandbox.readFile(path, { offset });
    offset += chunk.length;
    return chunk;
  };
  let chunk = await readNew(); // whatever's already there
  if (chunk.length) yield chunk;
  try {
    for await (const e of sandbox.getEventsStream({ signal })) {
      // React to writes only — our own readFile calls emit `read` events too.
      if (e.type === "fs.request" && e.operation === "write" && e.path === path) {
        chunk = await readNew();
        if (chunk.length) yield chunk;
      }
    }
  } finally {
    chunk = await readNew(); // final catch-up after the stream ends
    if (chunk.length) yield chunk;
  }
}

Render it live over a chunked response

Now serve it. Create the sandbox, start the generator, and forward each slice from tail into an HTTP response. Omitting Content-Length makes the server use Transfer-Encoding: chunked, so the browser renders the HTML incrementally — open the URL and watch the page paint as the model writes it. GENERATE_PY holds the script from the first step.

index.ts
import { createServer } from "node:http";
import { getOrCreateSandbox } from "@hiver.sh/client";

createServer(async (_req, res) => {
  const sandbox = await getOrCreateSandbox("streaming-demo", {
    image: "python",
    env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY },
    egress: [{ access: "allow", host: "api.anthropic.com" }],
  });
  const path = "/workspace/poem.html";
  await sandbox.writeFile(path, ""); // create it so the first read has something to follow
  await sandbox.writeFile("/workspace/generate.py", GENERATE_PY);

  // No Content-Length → Node uses chunked transfer encoding.
  res.writeHead(200, { "content-type": "text/html; charset=utf-8" });

  // Start the generator; stop tailing once it exits.
  const controller = new AbortController();
  const running = sandbox
    .exec(["python", "/workspace/generate.py"])
    .finally(() => controller.abort());

  for await (const chunk of tail(sandbox, path, controller.signal)) {
    res.write(chunk); // flush each slice to the browser
  }
  await running;
  res.end();
}).listen(3000, () => console.log("open http://localhost:3000"));
i
Reads are served from the sandbox's own filesystem, so the tail sees each write as soon as it's flushed — you don't wait on any remote sync. On a GCS- or Drive-backed mount the incremental writes are coalesced into whole-object uploads in the background, so streaming token-by-token into a synced file doesn't spray one upload per token. The events tell you when to read; the byte offset tells you from wherereadFile at end-of-file just returns an empty result.

Next: Execution