Isorun Docs
Sandboxes

Shell Sessions

Long-lived bash sessions inside a sandbox. State persists across commands.

A shell session is a single long-lived bash process inside the sandbox that survives across many commands. cd, export, shell variables, and background jobs all persist between calls, so the per-command fork cost a regular sandbox.exec() pays disappears.

Reach for this primitive when an AI agent runs many small commands in sequence, cd /repo && git diff && pytest && grep ..., and for interactive workflows where the user expects state to stick.

Why it's fast

A normal sandbox.exec("ls") does this every call:

  1. HTTP round-trip from your client to the runner.
  2. A connection from the runner to the in-sandbox agent.
  3. The agent forks sh -c "ls". The shell forks ls.
  4. ls runs.
  5. The agent reads stdout, sends it back over the runtime channel.
  6. HTTP response back to your client.

For 100 commands you pay all of that 100 times.

Inside a shell session:

  1. One WebSocket connection for the whole session.
  2. One persistent runtime channel between the runner and the agent.
  3. One long-running bash process inside the VM. Commands stream in over its stdin; output streams out over stdout/stderr.
  4. The agent injects a sentinel after each command so it knows where one command's output ends and the next begins. Exit codes ride along on the sentinel.

Result: each command is a write to the already-running bash, you skip the per-call setup a separate exec pays every time.

Open a session over WebSocket

The TypeScript SDK ships a sandbox.shell() helper in an upcoming release. Until it lands, talk to the WebSocket endpoint directly using your client of choice. The protocol below is the wire format the helper will speak.

sandbox.shell() isn't in the SDK yet. The WebSocket protocol shown here is stable and is what the helper will wrap once it ships.

TypeScript
import { WebSocket } from 'ws'
import { Isorun } from 'isorun'

const isorun = new Isorun()
const sandbox = await isorun.create({ image: 'python:3.12-slim' })

const ws = new WebSocket(
  `${isorun.apiUrl.replace(/^http/, 'ws')}/v1/runs/${sandbox.id}/shell`,
  { headers: { Authorization: `Bearer ${process.env.ISORUN_API_KEY}` } },
)

await new Promise<void>((resolve, reject) => {
  ws.once('open', () => resolve())
  ws.once('error', reject)
})

function run(id: string, command: string): Promise<{ exitCode: number; stdout: string; stderr: string }> {
  return new Promise((resolve) => {
    let stdout = '', stderr = ''
    const handler = (raw: any) => {
      const msg = JSON.parse(raw.toString())
      if (msg.id !== id) return
      if (msg.type === 'shell_out') stdout += msg.data
      else if (msg.type === 'shell_err') stderr += msg.data
      else if (msg.type === 'shell_exit') {
        ws.off('message', handler)
        resolve({ exitCode: msg.code, stdout, stderr })
      }
    }
    ws.on('message', handler)
    ws.send(JSON.stringify({ type: 'shell_run', id, command }))
  })
}

try {
  await run('r1', 'cd /tmp')          // cwd persists
  await run('r2', 'export FOO=bar')   // env persists
  const r = await run('r3', 'echo $FOO')
  console.log(r.stdout) // bar
} finally {
  ws.close()
  await sandbox.destroy()
}

What persists across commands

  • cwd (cd works)
  • environment variables (export FOO=bar)
  • shell variables (X=42; echo $X)
  • shell functions and aliases
  • background jobs (./long-running & keeps running)
  • shell history (in-memory)

What doesn't persist: anything that requires re-launching the shell itself (exec to replace the shell, exit). If your command kills the shell, the session ends and the client receives a shell_closed frame.

Streaming output

The shell streams stdout and stderr separately and incrementally, you don't have to wait for the command to exit to see partial output. The WebSocket frames are shell_out and shell_err chunks that arrive in real time. The helper class will surface this as a callback API; the raw WebSocket above already gives you the chunks.

Limits

ConstraintValue
Sessions per sandboxunbounded; each is one bash process
Session timeoutsandbox lifetime (or until you close the WebSocket)
Per-command overheadminimal: one write to the persistent bash
Bash death detectionshell_closed frame surfaces immediately

Choose between exec and shell

Use sandbox.exec()Use the shell session
One-off command per sandboxMany commands in sequence
You want command isolationYou want shared cwd / env / state
Don't care about per-call latencyLatency matters (agent loop, REPL)
Stateless toolsStateful workflows

Next steps

On this page