Skip to content
Guides · Agent cookbook

How to Send Input to a Running Command in a Sandbox

Updated October 28, 2026 .md

Short answer: Start the command with its stdin attached. Use sbx exec -i from a shell or as an MCP client's stdio command, or the exec WebSocket from code. A detached command gets no stdin and can't be attached to for input, so for a long-lived server use a named pipe or a socat bridge with sbx forward.

A stdio MCP server, a REPL or a CLI that asks questions all read from standard input. In a sandbox, the only open line to a process's stdin is the one it was started with. This page shows each way to hold that line open, the ways that look like they should work and don't, and two patterns for a server that has to outlive your connection.

If you only need to read a running command's output, see stream sandbox logs to a web UI. If you want each MCP server behind its own HTTPS URL, see run each MCP server in its own sandbox.

The options, measured#

We tested on prod on 2026-10-28 with one small sandbox in us-east. The program was the reference everything MCP server 2.0.1, installed in the sandbox with sudo npm i -g @modelcontextprotocol/server-everything@2.0.1. Our test machine had a 32 ms TCP connect time to sandboxer.com, so about 30 ms is one network round trip.

Way to send input Round trip per JSON-RPC call Process survives your disconnect
sbx exec -i <id> -- <cmd> (stdin over the exec WebSocket) 29–33 ms No: it gets SIGHUP
Exec WebSocket from code (sandbox.pty() in TypeScript, any WebSocket library elsewhere) 29–31 ms No
sbx exec --detach, then write to its stdin Not possible: stdin is empty Yes
POST /commands with a stdin field Not possible: the field is ignored n/a
Named pipe + detached command, write with exec, read with attach() 199–200 ms (first call 300 ms) Yes
socat on 127.0.0.1 as a systemd service + sbx forward 29–32 ms Yes, and the service restarts on boot
An HTTP bridge on a hosted URL One HTTPS request per call Yes; see one sandbox per MCP server

The WebSocket rows add almost nothing to the network. The named pipe costs a full API call per message plus the attach stream's 200 ms read interval.

1. Pipe input in with sbx exec -i#

-i keeps stdin open over a WebSocket and closes it when your input ends. Three JSON-RPC lines in a file:

cat > msgs.jsonl <<'EOF'
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"cli","version":"0"}}}
{"jsonrpc":"2.0","method":"notifications/initialized"}
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"echo","arguments":{"message":"hello from my laptop"}}}
EOF
time sbx exec -i sb_tlgdxpd6dx -- mcp-server-everything stdio < msgs.jsonl 2>/dev/null
{"result":{"protocolVersion":"2025-06-18","capabilities":{"prompts":{"listChanged":true},…
{"result":{"content":[{"type":"text","text":"Echo: hello from my laptop"}]},"jsonrpc":"2.0","id":2}

real	0m0.326s

The server's stdout comes back on your stdout and its stderr on your stderr, so 2>/dev/null hides its Starting default (STDIO) server... banner. The exit code is the remote one. Flags go before the --; everything after it is the command.

2. Use sbx exec -i as an MCP client's stdio command#

An MCP client that launches a stdio server can launch sbx instead. The server then runs in the sandbox while the client thinks it's local. With the MCP TypeScript SDK 1.32.2:

import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";

const transport = new StdioClientTransport({
  command: "sbx",
  args: ["exec", "-i", process.env.SANDBOX_ID, "--", "mcp-server-everything", "stdio"],
  env: process.env,          // passes SANDBOXER_API_KEY through to sbx
  stderr: "ignore",
});
const client = new Client({ name: "laptop", version: "1.0.0" });
const t0 = performance.now();
await client.connect(transport);
const v = client.getServerVersion();
console.log(`connected in ${Math.round(performance.now() - t0)} ms:`, v.name, v.version);
console.log("tools:", (await client.listTools()).tools.length);

const ms = [];
for (let i = 0; i < 10; i++) {
  const s = performance.now();
  const r = await client.callTool({ name: "echo", arguments: { message: `hi ${i}` } });
  ms.push(Math.round(performance.now() - s));
  if (i === 0) console.log(r.content[0].text);
}
console.log("echo round trips (ms):", ms.join(" "));
await client.close();
connected in 303 ms: mcp-servers/everything 2.0.1
tools: 16
Echo: hi 0
echo round trips (ms): 33 29 30 30 30 30 30 29 30 29

The 303 ms covers starting sbx, opening the WebSocket, starting Node in the sandbox and the MCP handshake. The server lives exactly as long as the client's session. When the client closes, or sbx dies, the server goes too:

(sleep 600 | sbx exec -i sb_tlgdxpd6dx -- mcp-server-everything stdio >/dev/null 2>&1) &
sleep 4; sbx exec sb_tlgdxpd6dx "pgrep -af [m]cp-server-everything"
pkill -f "sbx exec -i sb_tlgdxpd6dx"
sleep 3; sbx exec sb_tlgdxpd6dx "pgrep -af [m]cp-server-everything || echo none running"
633 node /usr/bin/mcp-server-everything stdio
none running

When the WebSocket closes, the sandbox sends the process group SIGHUP. That's the right default for a per-session server: nothing is left running.

3. Send input from code over the exec WebSocket#

GET /sandboxes/{id}/exec/ws is what sbx exec -i uses (API reference). The first text frame names the command. Binary frames you send are stdin. Binary frames you get back start with 1 (stdout) or 2 (stderr). In TypeScript, sandbox.pty() in sandboxer-sdk speaks it for you; in Node, also install ws. Set pty: false for a program that speaks a protocol on stdio, so you get plain pipes with no terminal echo and no \r\n:

import { Sandboxer } from "sandboxer-sdk";

const sandbox = new Sandboxer().sandbox(process.env.SANDBOX_ID);
const waiting = new Map();
let buf = "";
const session = await sandbox.pty({
  command: "mcp-server-everything stdio",
  pty: false,
  onData: (data, stream) => {
    if (stream !== "stdout") return;
    buf += Buffer.from(data).toString();
    for (let nl; (nl = buf.indexOf("\n")) >= 0; buf = buf.slice(nl + 1)) {
      const msg = JSON.parse(buf.slice(0, nl));
      waiting.get(msg.id)?.(msg);
      waiting.delete(msg.id);
    }
  },
});

let next = 1;
const rpc = (method, params = {}) => new Promise((resolve) => {
  const id = next++;
  waiting.set(id, resolve);
  session.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
});

const init = await rpc("initialize", { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "raw", version: "0" } });
console.log("server:", init.result.serverInfo.name, init.result.serverInfo.version);
session.write('{"jsonrpc":"2.0","method":"notifications/initialized"}\n');

const ms = [];
for (let i = 0; i < 10; i++) {
  const s = performance.now();
  await rpc("ping");
  ms.push(Math.round(performance.now() - s));
}
console.log("ping round trips (ms):", ms.join(" "));
const r = await rpc("tools/call", { name: "echo", arguments: { message: "over the WebSocket" } });
console.log(r.result.content[0].text);
session.eof();                                   // close stdin; the server exits
console.log("exit:", (await session.wait()).exitCode);
server: mcp-servers/everything 2.0.1
ping round trips (ms): 30 30 30 30 30 29 29 30 30 29
Echo: over the WebSocket
exit: 0

The Python SDK has no WebSocket helper, so use a WebSocket library. With websockets 17.3, the same exchange is:

import asyncio, json, os, time
import websockets

URL = f"wss://sandboxer.com/api/v1/sandboxes/{os.environ['SANDBOX_ID']}/exec/ws"

async def main():
    headers = {"Authorization": f"Bearer {os.environ['SANDBOXER_API_KEY']}"}
    async with websockets.connect(URL, additional_headers=headers) as ws:
        await ws.send(json.dumps({"command": "mcp-server-everything stdio", "pty": False}))
        buf = b""

        async def rpc(msg):
            nonlocal buf
            await ws.send(json.dumps(msg).encode() + b"\n")          # bytes = a binary frame = stdin
            while True:
                frame = await ws.recv()
                if isinstance(frame, bytes) and frame[:1] == b"\x01":  # 1 = stdout
                    buf += frame[1:]
                    while b"\n" in buf:
                        line, buf = buf.split(b"\n", 1)
                        reply = json.loads(line)
                        if reply.get("id") == msg["id"]:
                            return reply

        init = await rpc({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {
            "protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "py", "version": "0"}}})
        print("server:", init["result"]["serverInfo"]["name"])
        await ws.send(b'{"jsonrpc":"2.0","method":"notifications/initialized"}\n')
        ms = []
        for i in range(10):
            t = time.perf_counter()
            await rpc({"jsonrpc": "2.0", "id": 100 + i, "method": "ping"})
            ms.append(round((time.perf_counter() - t) * 1000))
        print("ping round trips (ms):", *ms)
        await ws.send(json.dumps({"type": "eof"}))                   # str = a text frame = control
        async for frame in ws:
            if isinstance(frame, str):
                print(frame)

asyncio.run(main())
server: mcp-servers/everything
ping round trips (ms): 30 29 29 29 30 30 30 30 30 29
{"type":"exit","exitCode":0}

Send stdin as bytes. A string becomes a text frame, which the sandbox reads as a control message, not input.

An open session doesn't need traffic to stay up. We left one idle for 7 minutes, then sent 10 pings (30–33 ms each) and an echo, and it exited cleanly. Still, any dropped connection ends the server, so a client that runs for hours should reconnect and send initialize again.

4. Drive an interactive CLI that wants a terminal#

Some programs only prompt when stdin is a terminal. Python's REPL prints no >>> over a plain pipe. For those, keep pty: true (the default in pty(), or sbx exec -it for a person at a keyboard) and wait for the prompt before you type:

let out = "";
const term = await sandbox.pty({ command: "python3 -q", cols: 100, rows: 30, onData: (d) => { out += Buffer.from(d).toString(); } });
const until = async (text) => { while (!out.includes(text)) await new Promise((r) => setTimeout(r, 20)); };

await until(">>> ");
term.write("2 ** 100\n");
await until("1267650600228229401496703205376");
term.write("exit()\n");
console.log("exit:", (await term.wait()).exitCode);
console.log(JSON.stringify(out));
exit: 0
">>> 2 ** 100\r\n1267650600228229401496703205376\r\n>>> exit()\r\n"

In a timed run, the answer came back 42 ms after the write. A terminal echoes what you type and ends lines with \r\n, and stdout and stderr arrive merged as stream 1. Parse with that in mind, or use pty: false whenever the program works without a terminal. Send term.signal("SIGINT") for Ctrl-C and term.resize(cols, rows) if the program draws to the screen.

What doesn't work: input to a detached command#

sbx exec --detach (POST /commands with detached: true, sandbox.start() in the SDK) starts a command that outlives your connection, but its stdin is empty from the start:

sbx exec sb_tlgdxpd6dx --detach "cat; echo cat-ended"
sbx exec sb_tlgdxpd6dx --status 615
{"ok":true,"type":"command.started","command":"cat; echo cat-ended",…,"processId":615,…}
{…,"exitCode":0,"finishedAt":"2026-10-28T11:05:22.921428296Z",…,"running":false,…,"startedAt":"2026-10-28T11:05:22.91420745Z","status":"exited",…,"stdout":"cat-ended\n",…}

cat read end-of-file at once and the command was over in 7 ms. A stdio MCP server started this way exits the same way. There's also no way to attach to a running process's stdin: sbx exec --attach, GET /commands/{processId}/stream and attach() only read output. And POST /commands doesn't take a stdin field: we sent {"command": "wc -c", "stdin": "hello\n"} and got "stdout":"0\n".

So if the process must keep running when nobody is connected, give it an input that doesn't depend on your connection.

Keep it running: a named pipe#

Start the server detached with a named pipe (FIFO) as stdin. Opening it with <> (read and write) means the server never sees end-of-file between writers:

sbx exec sb_tlgdxpd6dx --detach "rm -f ~/mcp.in; mkfifo ~/mcp.in && exec mcp-server-everything stdio <> ~/mcp.in"

The --detach reply gives the processId (646 for us) and the logPath of its stdout. After the initialize and notifications/initialized lines, each message is a short command that writes one line into the pipe. Replies are the detached command's stdout:

sbx exec sb_tlgdxpd6dx "echo '{\"jsonrpc\":\"2.0\",\"id\":8,\"method\":\"tools/call\",\"params\":{\"name\":\"echo\",\"arguments\":{\"message\":\"through the pipe\"}}}' > ~/mcp.in"
sbx exec sb_tlgdxpd6dx "tail -n 1 ~/.sandboxer/processes/646.log"
{"result":{"content":[{"type":"text","text":"Echo: through the pipe"}]},"jsonrpc":"2.0","id":8}

From code, write with exec() and keep an attach() stream open for the replies. OFFSET is the byte offset where your last read ended (stdoutNextOffset from sbx exec --status):

const pid = Number(process.env.PID);
const replies = new Map();
let buf = "";
const abort = new AbortController();
(async () => {
  for await (const f of sandbox.attach(pid, { stdoutOffset: Number(process.env.OFFSET ?? 0), signal: abort.signal })) {
    if (f.type !== "stdout") continue;
    buf += f.data;
    for (let nl; (nl = buf.indexOf("\n")) >= 0; buf = buf.slice(nl + 1)) {
      const msg = JSON.parse(buf.slice(0, nl));
      replies.get(msg.id)?.(msg);
    }
  }
})().catch((e) => { if (!abort.signal.aborted) throw e; });

const send = (msg) => sandbox.exec(`cat > /home/user/mcp.in <<'JSON'\n${JSON.stringify(msg)}\nJSON`);
const rpc = (id, method, params = {}) => new Promise((resolve) => {
  replies.set(id, resolve);
  send({ jsonrpc: "2.0", id, method, params });
});
for (let i = 0; i < 5; i++) await rpc(2000 + i, "tools/call", { name: "echo", arguments: { message: `fifo ${i}` } });
abort.abort();

Our run, with timings added:

Echo: fifo 0
fifo round trips (ms): 300 199 199 200 200

About 200 ms per call, because the attach stream reads the output every 200 ms. In exchange, any client can come and go: a new one starts attaching from the last offset it saw. Only one client should write at a time, since all writers share one server session. A detached command doesn't come back by itself after a stop that boots from disk; for that, use a service, as below.

Keep it running: a TCP bridge with socat#

socat is installed in sandboxes. It can listen on a port and give each connection its own copy of the stdio server, wired to the socket. As a systemd unit with Restart=always, it starts on boot and again if it crashes:

sudo tee /etc/systemd/system/mcp-bridge.service >/dev/null <<'UNIT'
[Unit]
Description=stdio MCP server on 127.0.0.1:7000, one process per TCP connection
After=network-online.target

[Service]
User=user
ExecStart=/usr/bin/socat TCP-LISTEN:7000,bind=127.0.0.1,reuseaddr,fork EXEC:"mcp-server-everything stdio"
Restart=always

[Install]
WantedBy=multi-user.target
UNIT
sudo systemctl daemon-reload && sudo systemctl enable --now mcp-bridge

We piped that script in with sbx exec -i <id> "bash -s" < bridge.sh. Bind to 127.0.0.1: each sandbox has a public IPv6 address, and the bridge has no authentication. Leave out socat's stderr option, which would mix the server's banner into the JSON stream; without it, stderr goes to journalctl -u mcp-bridge.

Then bring the port to your machine over the authenticated tunnel and talk to it like any socket:

sbx forward sb_tlgdxpd6dx --remote 7000 --local 17000
{"event":"forwarding","id":"sb_tlgdxpd6dx","reverse":false,"local":17000,"remote":7000,"bind":"127.0.0.1"}

A Node client that writes JSON-RPC lines to 127.0.0.1:17000, run twice:

initialize 198 ms: mcp-servers/everything
ping round trips (ms): 32 30 30 29 31 30 30 30 30 30
initialize 191 ms: mcp-servers/everything
ping round trips (ms): 32 30 30 31 31 31 31 31 31 30
Oct 28 11:06:41 warm-balmy-larch socat[831]: Starting default (STDIO) server...
Oct 28 11:06:42 warm-balmy-larch socat[839]: Starting default (STDIO) server...

Each connection got its own server process, which is how a stdio server expects to be used. Calls cost the same 30 ms as the WebSocket.

We stopped the sandbox with sbx stop --wait and resumed it. memoryRestored was true, and both the named-pipe server (still pid 646) and the socat service were running. After starting sbx forward again, the client initialized in 194 ms and pinged in 30–31 ms. When a resume boots from disk instead (memoryRestored: false), running processes are lost: the detached pipe server is gone, and only an enabled systemd unit like this one starts again. We didn't force a cold boot for this page; detect a cold boot after resume shows how to check for one.

If the client needs an HTTPS URL instead of a local port (a hosted agent, a teammate), put an HTTP bridge in front of the server and host the port privately. That setup, with the cookie header and the systemd details, is in run each MCP server in its own sandbox.

Which one to use#

Your case Use
A local MCP client, one server per session sbx exec -i <id> -- <server> as the client's stdio command
Your own agent code talks to the program The exec WebSocket (pty({ pty: false }), or a WebSocket library)
A REPL or a CLI that prompts The exec WebSocket with pty: true
A server that must outlive the client, low traffic Named pipe + detached command
A server that must outlive the client and start again on boot socat as a systemd service + sbx forward
A remote client that needs a URL HTTP bridge + sbx host --localhost

FAQ#

Can I attach to a detached command and type into it?#

No. Attach only reads output, and a detached command's stdin is empty from the start. Start it with sbx exec -i or the exec WebSocket if you need its stdin, or give it a named pipe or a socket as input.

What happens to the process if my connection drops?#

A process started over the exec WebSocket gets SIGHUP when the socket closes, and in our test it was gone within 3 seconds. Use the named pipe or the socat service when it has to keep running.

Does sbx exec -i need a terminal on my side?#

No. Without -t it uses plain pipes, so you can pipe a file in or let a program spawn it, as an MCP client does. Add -t only for a person typing into a shell or a full-screen program.

Why is the named pipe slower?#

Each message is its own API call, and replies come through the attach stream, which reads the output every 200 ms. The WebSocket and the forwarded socket keep one connection open, so a call costs one network round trip.

More guides

Top AI sandbox platforms for code execution in 2026, ranked

For running AI-written code we rank Sandboxer, Freestyle and E2B first: each gives code its own VM with an API or SDK and can pause with memory. Pick Daytona for Windows or GPUs, Modal or Northflank for GPUs at scale, E2B or Northflank to run in your own cloud, and Vercel Sandbox if your app lives on Vercel.

Read the guide
Best sandboxes for running coding agents (Claude Code, Codex, Gemini CLI) in 2026, ranked

For coding agents we rank Sandboxer first: a full Ubuntu VM with Claude Code and Codex preinstalled, Docker, and stop-and-resume that keeps the agent's session in memory. Freestyle and Amp Orbs also keep memory across pauses. Pick E2B for prebuilt agent templates on open source, Docker Sandboxes for free local runs, and Codespaces if you live in GitHub.

Read the guide
Best Daytona sandbox alternatives in 2026, ranked

The best Daytona alternatives depend on why you're switching. For a full Linux microVM on every sandbox with memory-saving stop and Docker, we rank Sandboxer first. E2B suits open source and self-hosting, Modal massive short-job fleets, Northflank bring-your-own-cloud with GPUs, and Vercel Sandbox apps on Vercel.

Read the guide
Best E2B alternatives in 2026, ranked

If you're leaving E2B for longer sessions or a fuller machine, we rank Sandboxer first, then Freestyle: both give each sandbox a full Linux VM that keeps memory across pauses, with Docker and no short session cap. Pick Daytona for Windows or GPUs, Northflank for bring-your-own-cloud, and stay on E2B if you need open source.

Read the guide