# How to Send Input to a Running Command in a Sandbox

> 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.

Source: https://sandboxer.com/guides/send-input-to-a-running-command-in-a-sandbox. Updated 2026-10-28.

**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](/guides/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](/guides/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](https://github.com/modelcontextprotocol/servers/tree/main/src/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](/guides/run-each-mcp-server-in-its-own-sandbox) |

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:

```bash
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
```

```text
{"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](https://github.com/modelcontextprotocol/typescript-sdk) 1.32.2:

```javascript
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();
```

```text
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:

```bash
(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"
```

```text
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](/docs/api#interactive-exec-over-websocket)). 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`:

```javascript
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);
```

```text
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`](https://websockets.readthedocs.io/) 17.3, the same exchange is:

```python
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())
```

```text
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:

```javascript
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));
```

```text
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:

```bash
sbx exec sb_tlgdxpd6dx --detach "cat; echo cat-ended"
sbx exec sb_tlgdxpd6dx --status 615
```

```text
{"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:

```bash
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:

```bash
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"
```

```text
{"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`):

```javascript
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:

```text
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:

```bash
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:

```bash
sbx forward sb_tlgdxpd6dx --remote 7000 --local 17000
```

```text
{"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:

```text
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
```

```text
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](/guides/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](/guides/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.

## Related

- [Stream sandbox logs to a web UI](/guides/stream-sandbox-logs-to-a-web-ui)
- [Run each MCP server in its own sandbox](/guides/run-each-mcp-server-in-its-own-sandbox)
- [Run a service that comes back after resume](/guides/run-a-service-that-comes-back-after-resume)
- [CLI reference](/docs/cli)
