DocsInteractive terminal

Interactive terminal

Open an interactive login shell in a sandbox and drive it with raw bytes instead of one command at a time: send it keystrokes, resize it, reconnect to it from another process, and kill it.

Use a terminal when the session has to keep state between keystrokes: a REPL, a program that asks questions, or a terminal you render in a browser. For one-off commands use Run commands instead.

Every example reads IMPELLO_API_KEY from the environment. Make a key at dashboard.impello.ai/keys.

Create a terminal

sandbox.pty.create() starts /bin/bash -i -l attached to a pseudo-terminal of the size you give it and returns a handle. The handle's pid identifies the terminal in every later call.

import { Sandbox } from '@impello/sdk'

const sandbox = await Sandbox.create('base')
const decoder = new TextDecoder()

const terminal = await sandbox.pty.create({
  cols: 80,
  rows: 24,
  onData: (data) => {
    process.stdout.write(decoder.decode(data, { stream: true }))
  },
})

console.log(terminal.pid)

Output arrives as bytes, not text. In TypeScript onData receives a Uint8Array for every chunk the shell writes. Decode it with a streaming TextDecoder so a multi-byte character split across two chunks survives. The sync Python create() takes no callback. Read the output by iterating the handle, or pass on_pty to wait(), both shown below. Python hands you the raw bytes either way, so decode them with an incremental decoder for the same reason.

The shell starts with TERM=xterm-256color, LANG=C.UTF-8 and LC_ALL=C.UTF-8. Anything you pass in envs overrides those.

Options

pty.create() takes these options:

TypeScriptPythonDefaultWhat it does
cols, rowssize: PtySizerequiredTerminal size in characters. PtySize takes rows then cols.
onDataon_data (async only)required in TypeScript and async PythonCalled with each chunk of output. The sync Python create() has no callback.
timeoutMstimeout60000 ms / 60 sHow long the terminal may run. When it passes, the sandbox kills the shell. 0 removes the limit.
useruserthe template's userLinux user the shell runs as. The base template's user is user.
envsenvs{} plus TERM, LANG and LC_ALLEnvironment variables for the shell.
cwdcwdthat user's home directoryWorking directory the shell starts in.

To keep a terminal open longer, raise timeoutMs (timeout in Python, in seconds). timeoutMs: 0 in TypeScript and timeout=0 in Python remove the limit.

The sandbox's own timeout also ends the terminal. When it runs out, the terminal goes with everything else inside the sandbox: killed, or paused if the sandbox's lifecycle says so. See Timeouts.

Send input

Send keystrokes as bytes with pty.sendInput() in TypeScript or pty.send_stdin() in Python. The names differ by more than casing. Include the newline, because the shell does not run a line until it sees one.

const encoder = new TextEncoder()

await sandbox.pty.sendInput(terminal.pid, encoder.encode('echo hello\n'))
await sandbox.pty.sendInput(terminal.pid, encoder.encode('exit\n'))

await terminal.wait()
await sandbox.kill()

Control characters are bytes like any other: send \x03 for Ctrl-C and \x04 for Ctrl-D. wait() returns once the shell exits, and a non-zero exit code raises CommandExitError (CommandExitException in Python). See Errors.

The handle is a CommandHandle, the same class a background command returns, but a terminal has no separate stdin. handle.sendStdin() and handle.closeStdin() (handle.send_stdin() and handle.close_stdin() in Python) throw SandboxError (SandboxException in Python) on it. Always send input through sandbox.pty.

Resize the terminal

Call resize() whenever the window the terminal is drawn in changes size, so full-screen programs redraw at the right width.

await sandbox.pty.resize(terminal.pid, { cols: 120, rows: 40 })

Reconnect to a terminal

A terminal keeps running after the handle that created it goes away, until its timeoutMs passes. You can pick it up from another process or after a network drop. connect() takes the pid and returns a fresh handle.

sandbox.commands.list() returns every running command, terminal and template start command as a ProcessInfo. A terminal is the entry whose args start with -i: commands run as /bin/bash -l -c <command>, terminals as /bin/bash -i -l.

import { Sandbox } from '@impello/sdk'

const sandbox = await Sandbox.connect(process.env.SANDBOX_ID as string)
const decoder = new TextDecoder()

const running = await sandbox.commands.list()
const shell = running.find((p) => p.args[0] === '-i')

if (shell) {
  const terminal = await sandbox.pty.connect(shell.pid, {
    onData: (data) => {
      process.stdout.write(decoder.decode(data, { stream: true }))
    },
    timeoutMs: 60 * 60 * 1000,
  })
  await terminal.wait()
}

The sync Python handle is iterable and yields (stdout, stderr, pty) tuples with one field set. For a terminal it is always the third.

connect() takes timeoutMs (timeout) with the same 60 second default as create(). On connect() it bounds only how long the new handle listens. When it passes, wait() throws TimeoutError and the shell keeps running. The examples above pass their own bound so they stay attached for longer. Call handle.disconnect() to stop receiving output without killing the shell.

Kill a terminal

pty.kill() sends SIGKILL to the shell. It returns true when the terminal was killed and false when no process has that pid.

await sandbox.pty.kill(terminal.pid)

handle.kill() does the same for the handle's own pid. Killing the sandbox kills every terminal in it.

Async Python

AsyncSandbox has the same pty module with every method awaited. The async create() and connect() take an on_data callback, because there is no sync iterator to read from, and wait() takes no callbacks.

import asyncio
import codecs

from impello import AsyncSandbox, PtySize

decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")


async def main():
    sandbox = await AsyncSandbox.create("base")

    terminal = await sandbox.pty.create(
        PtySize(rows=24, cols=80),
        on_data=lambda data: print(decoder.decode(data), end=""),
    )

    await sandbox.pty.send_stdin(terminal.pid, b"echo hello\n")
    await sandbox.pty.send_stdin(terminal.pid, b"exit\n")
    await terminal.wait()
    await sandbox.kill()


asyncio.run(main())

Next steps