Interactive agents
Run an agent’s native terminal interface inside a sandbox. Stream its screen, send keyboard input, and reconnect to the same process.
For headless tasks and structured messages, use agents and sessions. For an ordinary shell, use terminals.
Start a native TUI
Use Node.js 22 or later.
Set TANGLE_API_KEY and TANGLE_SANDBOX_ID to a running sandbox you own.
For a budgeted fleet, obtain box with await fleet.sandbox(machineId) from the original fleet instance.
Install the SDK and its profile helpers:
npm install @tangle-network/sandbox @tangle-network/agent-interface
npm install --save-dev tsxSave this program as start-tui.ts and run npx tsx start-tui.ts in your terminal.
It starts Claude Code with a prompt, streams its actual terminal output, and forwards your keyboard input.
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { Sandbox } from "@tangle-network/sandbox";
import {
agentInteractiveSessionRunRef,
canonicalAgentProfileDigest,
type AgentProfile,
} from "@tangle-network/agent-interface";
if (!process.stdin.isTTY || !process.stdout.isTTY) throw new Error("Run this in a terminal");
const apiKey = process.env.TANGLE_API_KEY;
const sandboxId = process.env.TANGLE_SANDBOX_ID;
if (!apiKey || !sandboxId) throw new Error("Set TANGLE_API_KEY and TANGLE_SANDBOX_ID");
const client = new Sandbox({ apiKey, baseUrl: "https://sandbox.tangle.tools" });
const box = await client.get(sandboxId);
if (!box) throw new Error("Sandbox not found");
const support = (await box.capabilities())?.interactiveAgent;
if (!support?.start || !support.attach || !support.control || !support.input || !support.resize) {
throw new Error("This sandbox runtime does not support native agent terminals");
}
const sessionId = randomUUID();
const profile: AgentProfile = { harness: "claude-code" };
const input = {
profile,
requestedProfileDigest: canonicalAgentProfileDigest(profile),
initialPrompt: "List the workspace files without changing them.",
cols: process.stdout.columns || 100,
rows: process.stdout.rows || 30,
};
const run = agentInteractiveSessionRunRef({
provider: "tangle-sandbox",
environmentId: box.id,
sessionId,
executionId: randomUUID(),
}, input);
const tui = box.session(sessionId).interactive();
const started = await tui.start({ ...input, run });
if (started.state !== "running") throw new Error("The agent terminal has exited");
const refFile = `tangle-tui-${sessionId}.json`;
await writeFile(refFile, JSON.stringify(started.ref, null, 2), { mode: 0o600, flag: "wx" });
console.log(`Session reference: ${refFile}. Press Ctrl-] to detach.`);
let finish = () => {};
let terminalError: Error | undefined;
const detached = new Promise<void>((resolve) => { finish = resolve; });
const terminal = await tui.attach({
cols: process.stdout.columns || 100,
rows: process.stdout.rows || 30,
handlers: {
onData: (data) => { process.stdout.write(data); },
onExit: () => finish(),
onClose: () => finish(),
onError: (error) => { terminalError = error; finish(); },
},
});
const onInput = (data: Buffer) => {
if (!terminal.isOpen) { finish(); return; }
const detachAt = data.indexOf(0x1d); // Ctrl-]
if (detachAt >= 0) {
if (detachAt > 0) terminal.write(data.subarray(0, detachAt));
finish();
} else {
terminal.write(data);
}
};
const onResize = () => {
if (terminal.isOpen) terminal.resize(process.stdout.columns || 100, process.stdout.rows || 30);
};
const wasRaw = process.stdin.isRaw;
const restoreTerminal = () => { process.stdin.setRawMode(wasRaw); };
process.once("exit", restoreTerminal);
process.stdin.setRawMode(true);
process.stdin.on("data", onInput);
process.stdout.on("resize", onResize);
process.stdin.resume();
await detached;
process.stdin.off("data", onInput);
process.stdout.off("resize", onResize);
restoreTerminal();
process.off("exit", restoreTerminal);
process.stdin.pause();
await terminal.close();
if (terminalError) throw terminalError;Press Ctrl-] to detach while keeping the agent running. Other keys, including Ctrl-C, go to the agent’s terminal. Complete any first-run setup in the terminal before the agent begins its task. Resizing your terminal updates the remote screen. The program prints the filename containing the session reference before attaching.
profile.harness selects the agent.
The runtime supports native launchers for Claude Code, Codex, Pi, OpenCode, Factory Droids, Kimi Code, Amp, and Hermes.
The selected agent must be installed and authenticated in the sandbox.
Use initialPrompt with Claude Code, Codex, Pi, or OpenCode.
For other agents, omit it and type after the TUI is ready.
Check (await box.capabilities())?.interactiveAgent before relying on native terminal operations.
Reconnect to the same agent
Save this as reattach-tui.ts.
Pass the reference filename printed by the start program: npx tsx reattach-tui.ts tangle-tui-SESSION_ID.json.
import { readFile } from "node:fs/promises";
import { Sandbox } from "@tangle-network/sandbox";
import { AgentInteractiveSessionRefSchema } from "@tangle-network/agent-interface";
if (!process.stdin.isTTY || !process.stdout.isTTY) throw new Error("Run this in a terminal");
const apiKey = process.env.TANGLE_API_KEY;
const sandboxId = process.env.TANGLE_SANDBOX_ID;
if (!apiKey || !sandboxId) throw new Error("Set TANGLE_API_KEY and TANGLE_SANDBOX_ID");
const client = new Sandbox({ apiKey, baseUrl: "https://sandbox.tangle.tools" });
const box = await client.get(sandboxId);
if (!box) throw new Error("Sandbox not found");
const refFile = process.argv[2];
if (!refFile) throw new Error("Pass the saved session JSON filename");
const ref = AgentInteractiveSessionRefSchema.parse(JSON.parse(await readFile(refFile, "utf8")));
if (ref.run.environmentId !== box.id) throw new Error("Session belongs to a different sandbox");
const tui = box.session(ref.run.sessionId).interactive({ ref });
const support = (await box.capabilities())?.interactiveAgent;
if (!support?.reattach || !support.attach || !support.control) {
throw new Error("This sandbox runtime does not support reattaching agent terminals");
}
console.log("Press Ctrl-] to detach.");
let finish = () => {};
let terminalError: Error | undefined;
const detached = new Promise<void>((resolve) => { finish = resolve; });
const terminal = await tui.attach({
cols: process.stdout.columns || 100,
rows: process.stdout.rows || 30,
handlers: {
onData: (data) => { process.stdout.write(data); },
onExit: () => finish(),
onClose: () => finish(),
onError: (error) => { terminalError = error; finish(); },
},
});
const onInput = (data: Buffer) => {
if (!terminal.isOpen) { finish(); return; }
const detachAt = data.indexOf(0x1d); // Ctrl-]
if (detachAt >= 0) {
if (detachAt > 0) terminal.write(data.subarray(0, detachAt));
finish();
} else {
terminal.write(data);
}
};
const onResize = () => {
if (terminal.isOpen) terminal.resize(process.stdout.columns || 100, process.stdout.rows || 30);
};
const wasRaw = process.stdin.isRaw;
const restoreTerminal = () => { process.stdin.setRawMode(wasRaw); };
process.once("exit", restoreTerminal);
process.stdin.setRawMode(true);
process.stdin.on("data", onInput);
process.stdout.on("resize", onResize);
process.stdin.resume();
await detached;
process.stdin.off("data", onInput);
process.stdout.off("resize", onResize);
restoreTerminal();
process.off("exit", restoreTerminal);
process.stdin.pause();
await terminal.close();
if (terminalError) throw terminalError;The SDK reads the existing session, claims input control, and attaches to that process. It does not start another agent. Keep the complete reference; a session ID alone does not identify a specific process incarnation. A stopped or expired session cannot be reattached.
Stop the agent
Detach the viewer first.
Save this as stop-tui.ts and pass the same reference filename: npx tsx stop-tui.ts tangle-tui-SESSION_ID.json.
import { readFile } from "node:fs/promises";
import { Sandbox } from "@tangle-network/sandbox";
import { AgentInteractiveSessionRefSchema } from "@tangle-network/agent-interface";
const apiKey = process.env.TANGLE_API_KEY;
const sandboxId = process.env.TANGLE_SANDBOX_ID;
if (!apiKey || !sandboxId) throw new Error("Set TANGLE_API_KEY and TANGLE_SANDBOX_ID");
const client = new Sandbox({ apiKey, baseUrl: "https://sandbox.tangle.tools" });
const box = await client.get(sandboxId);
if (!box) throw new Error("Sandbox not found");
const refFile = process.argv[2];
if (!refFile) throw new Error("Pass the saved session JSON filename");
const ref = AgentInteractiveSessionRefSchema.parse(JSON.parse(await readFile(refFile, "utf8")));
if (ref.run.environmentId !== box.id) throw new Error("Session belongs to a different sandbox");
const tui = box.session(ref.run.sessionId).interactive({ ref });
const support = (await box.capabilities())?.interactiveAgent;
if (!support?.stop) throw new Error("This sandbox runtime does not support stopping agent terminals");
const result = await tui.stopLifecycle();
console.log(result.status, result.effect);stopLifecycle() stops the referenced agent process.
The sandbox and its files remain available.
Delete the sandbox separately when its work is finished.
Send input from your application
| Operation | API |
|---|---|
| Start with a task | tui.start({ ...options, initialPrompt }) |
| Stream terminal bytes | tui.attach({ handlers: { onData } }) |
| Send keystrokes | terminal.write(data) |
| Resize the screen | terminal.resize(cols, rows) |
| Detach the viewer | terminal.close() |
| Inspect the process | tui.status() |
| Stop the process | tui.stopLifecycle() |
write() sends terminal input, including control keys and escape sequences.
It is not a structured agent-message API.
Use sendPrompt() when you need a prompt delivery acknowledgement bound to the active process and input-control claim.
That acknowledgement confirms delivery, not completion of the agent’s task.
Use headless execution when your application needs a structured task result.
Embed a TUI in your product
Use BrowserInteractiveSessionController from @tangle-network/sandbox/interactive-control to attach a browser terminal.
Render onData bytes with a terminal renderer such as xterm.js, and forward its input and resize events.
For typed terminal frames with replay identifiers, use attachAgentTerminal().
Your backend must check the user’s access before issuing scoped credentials. Keep account API keys on the server. A browser viewer attaches to an existing session; opening the viewer should not provision or replace a sandbox.