---
title: The terminal
description: One-shot commands as argv, interactive ptys, and why a non-zero exit code is a success.
---

Two ways to run something: a one-shot `exec` over HTTP, and a pty you attach to over the
gateway. Neither is gated on the input lease — a shell is a session of its own, and two people
holding separate terminals is normal. The lease governs the desktop, where there is one mouse
and one keyboard.

## exec

```ts
const result = await machine.terminal.exec(["git", "clone", url]);
result.stdout;
result.stderr;
result.exit_code;
```

`command` is **argv**, not a string. There is no shell and no tokenizing, so an argument
containing a space, a quote or a semicolon is one argument. That is the whole defence against
command injection, and it only works because the API refuses to split a string for you.

| Field        | Meaning                     |
| ------------ | --------------------------- |
| `command`    | argv, required              |
| `user`       | `agent` (default) or `root` |
| `cwd`        | working directory           |
| `stdin`      | fed to the process          |
| `timeout_ms` | how long it may run         |

### A non-zero exit code is a successful call

Only a failure to run the command at all is an error. A command that runs and returns 1 is a
`200` with `exit_code: 1` — the code is data, not a fault.

That distinction is worth holding onto in an agent loop. A model that sees an exception on every
failed grep learns to avoid grep; a model that sees an exit code learns to read it.

### Asking for a shell by name

`exec` deliberately does not tokenize, so pipes, globs and redirects need a shell, spelled out:

```ts
await machine.terminal.execShell("cd /tmp/site && python3 -m http.server 3000 &");
// which is exactly:
await machine.terminal.exec(["/bin/sh", "-lc", "cd /tmp/site && python3 -m http.server 3000 &"]);
```

The point is that a reader can see you asked for it. Guessing where a command ends is how
injection happens.

## Interactive terminals

| Call                                        | What it does   |
| ------------------------------------------- | -------------- |
| `POST /machines/{id}/terminals`             | open a pty     |
| `GET /machines/{id}/terminals`              | list open ptys |
| `DELETE /machines/{id}/terminals/{term_id}` | kill one       |

```ts
const term = await machine.terminal.connect(); // opens one if you pass no id
term.write("ls -la\n");
term.resize(120, 32);
for await (const bytes of term) process.stdout.write(bytes);
term.close(); // closes the pty inside the machine
```

`open` takes `command`, `cwd`, `user`, `cols` and `rows`. Bytes go both ways unmodified over
[the terminal channel](/docs/reference/realtime); a terminal already holding a client refuses a
second one at the handshake with close code `4409`.

The CLI's `raster shell` is this, with your local terminal put into raw mode so `^C` reaches
the remote process rather than being handled locally.
