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.requestwrite wakes it up, and it reads only the new bytes with a byteoffset— 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:
# 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 awayThe 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.
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:
// 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.
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"));offset tells you from where — readFile at end-of-file just returns an empty result.Next: Execution