# Pseudo Terminal (PTY) (/docs/pty)

Daytona provides pseudo terminal (PTY) capabilities through the process module in sandboxes. A PTY is a process started with the PTY kind: it runs under a virtual terminal so programs behave as if connected to a real terminal, with terminal size, line discipline, and interactive prompts.

PTY processes are exposed through a sandbox's PTY namespace and share the handle API of other processes, described in [process execution](/docs/process-execution).

<PtyVisual />

## Use cases [#use-cases]

PTY sessions are used when a program expects a real terminal.

- **Interactive development.** REPLs, debuggers, and editors that read keystrokes and redraw the screen run in a PTY session. The virtual terminal gives them a terminal size and line discipline, as a real terminal does. The terminal starts at 80 columns by 24 rows unless the start request sets a size.

- **Terminals in web applications.** A browser terminal connects to a PTY session, sends keystrokes, and resizes the session with the window. On each resize, the program in the terminal receives a window size change signal and can redraw. Browsers authenticate the WebSocket connection with the daytona_token query parameter.

- **Builds and tests.** A PTY session runs compilers, test runners, and deploy tools that show progress or colors only when attached to a terminal. Waiting for the session returns its exit code, and the CLI exits with the same code. If the wait time elapses first, the result reports a timeout with no exit code, and the session keeps running.

- **Reconnects.** A client that reconnects replays the output from a cursor and sees what it missed. Every output frame carries a cursor, and a read with a cursor returns only the frames after it. Retention can evict old output, and a cursor into evicted output fails.

- **Terminal agents.** An agent works in one interactive shell that keeps its working directory and variables between commands. It reads the output as a terminal shows it, including escape sequences. When a session timeout elapses, the session is killed and reports that it timed out.

## Start PTY session [#start-pty-session]

Start a PTY session. Without a command, the session runs an interactive shell: the login shell of the user it runs as, or the first of bash, zsh, and sh found in the image. With a command, the session runs that command in the terminal instead. The terminal starts at 80 columns by 24 rows unless you set a size, and you can [resize](#resize-pty-session) it later.

The SDKs and the CLI keep an exited session's record and output until [deleted](#kill-pty-session).

<Tabs syncKey="language">
<TabItem label="Python" icon="seti:python">

```python
from daytona import Daytona

daytona = Daytona()
sandbox = daytona.v2.create()

# Without a command the PTY runs an interactive shell; the terminal defaults to 80x24
handle = sandbox.pty.start()
print(handle.id)
```

</TabItem>
<TabItem label="TypeScript" icon="seti:typescript">

```typescript
import { Daytona } from '@daytona/sdk'

const daytona = new Daytona()
const sandbox = await daytona.v2.create()

// Without a command the PTY runs an interactive shell; the terminal defaults to 80x24
const handle = await sandbox.pty.start()
console.log(handle.id)
```

</TabItem>
<TabItem label="CLI" icon="seti:shell">

```bash
# The CLI needs a command, so this starts bash; requires a terminal, and the PTY takes its size
daytona sandbox exec {sandboxId} --tty -- bash
```

| **Flag**    | **Type** | **Description**                                                                                                                                  |
| ----------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| `--timeout` | duration | Limits how long `exec` stays attached. When the limit elapses, the CLI exits with code `8` and the session keeps running. The default is `10m`. |

<Callout type="note" title="Notes">
- The request requires the `write:sandboxes` permission.
- The CLI runs the arguments after `--` as a program, without a shell.
- Wrap shell syntax in `bash -c '...'`.
</Callout>

</TabItem>
<TabItem label="API" icon="seti:json">

```bash
# Without shellCommand the PTY runs an interactive shell; terminal defaults to 80x24.
# The SDKs set keepLogs to until_cleanup; the API default for unnamed processes is on_exit_ttl.
curl '{toolboxProxyUrl}/{sandboxId}/processes' \
  --request POST \
  --header 'Content-Type: application/json' \
  --header 'Authorization: Bearer YOUR_API_KEY' \
  --data '{"kind": "pty", "stdin": "open", "keepLogs": "until_cleanup"}'
```

<Callout type="note" title="Notes">
- The request requires the `write:sandboxes` permission.
- `{toolboxProxyUrl}` is the `toolbox_proxy_url` field of the [sandbox](/docs/sandboxes#get-a-sandbox).
</Callout>

</TabItem>
</Tabs>

<Tabs syncKey="language">
<TabItem label="Python" icon="seti:python">

```python
from daytona import Daytona
from daytona.v2 import TerminalSize

daytona = Daytona()
sandbox = daytona.v2.create()

handle = sandbox.pty.start("python3 -i", terminal=TerminalSize(cols=100, rows=40))
```

| **Parameter** | **Type** | **Description** |
| ------------- | -------- | --------------- |
| `command` | str | Sets the command to run in the PTY. |
| `terminal` | TerminalSize | Sets the terminal size. `cols` and `rows` are each from `1` to `65535`. The default is 80 columns by 24 rows. |
| `cwd` | str | Sets the working directory of the command. |
| `env` | Mapping\[str, str\] | Sets environment variables for the command. |
| `name` | str | Sets the session name. See [name PTY session](#name-pty-session). |
| `keep_logs` | KeepLogs | Sets the log retention policy. With `"on_exit_ttl"`, the record and output are removed 10 minutes after exit; with `"none"`, the output is discarded at exit. See [log retention](/docs/log-streaming#log-retention). The default is `"until_cleanup"`. |

</TabItem>
<TabItem label="TypeScript" icon="seti:typescript">

```typescript
import { Daytona } from '@daytona/sdk'

const daytona = new Daytona()
const sandbox = await daytona.v2.create()

const handle = await sandbox.pty.start('python3 -i', {
  terminal: { cols: 100, rows: 40 },
})
```

| **Parameter** | **Type**       | **Description**                                                                                                                                  |
| ------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| `terminal`    | TerminalSize | Sets the terminal size. `cols` and `rows` are each from `1` to `65535`. The default is 80 columns by 24 rows. |
| `cwd`         | string       | Sets the working directory of the command. |
| `env`         | Record\<string, string\> | Sets environment variables for the command. |
| `name`        | string       | Sets the session name. See [name PTY session](#name-pty-session). |
| `keepLogs`    | KeepLogs     | Sets the log retention policy. With `'on_exit_ttl'`, the record and output are removed 10 minutes after exit; with `'none'`, the output is discarded at exit. See [log retention](/docs/log-streaming#log-retention). The default is `'until_cleanup'`. |

</TabItem>
<TabItem label="CLI" icon="seti:shell">

```bash
# The PTY takes the size of the local terminal; the CLI cannot set cols and rows
daytona sandbox exec {sandboxId} --tty -- python3 -i
```

</TabItem>
<TabItem label="API" icon="seti:json">

```bash
curl '{toolboxProxyUrl}/{sandboxId}/processes' \
  --request POST \
  --header 'Content-Type: application/json' \
  --header 'Authorization: Bearer YOUR_API_KEY' \
  --data '{
  "kind": "pty",
  "shellCommand": "python3 -i",
  "stdin": "open",
  "terminal": {"cols": 100, "rows": 40}
}'
```

| **Field**  | **Type** | **Description**                                              |
| ---------- | -------- | ------------------------------------------------------------ |
| `terminal` | object | Sets the terminal size with `cols` and `rows`. Both are required, and each is from `1` to `65535`. The field is accepted only with `kind: "pty"`. |

</TabItem>
</Tabs>

## Name PTY session [#name-pty-session]

Name a PTY session. A name aliases the session ID, so another client can reach the session without knowing its ID. The name belongs to the session only while it runs: when the session exits, the name is released for reuse, and the session stays reachable by its ID only.

<Tabs syncKey="language">
<TabItem label="Python" icon="seti:python">

```python
from daytona import Daytona

daytona = Daytona()
sandbox = daytona.v2.create()

handle = sandbox.pty.start(name="dev-shell")

# Names are unique among live processes and resolve wherever an ID is accepted
info = sandbox.process.get("dev-shell")
```

<Callout type="note" title="Notes">
- The request requires the `write:sandboxes` permission.
- A name has 1 to 128 characters of letters, digits, `.`, `_`, and `-`, and cannot be `.` or `..`.
- The names `entrypoint` and `dockerd` are reserved for system processes.
</Callout>

</TabItem>
<TabItem label="TypeScript" icon="seti:typescript">

```typescript
import { Daytona } from '@daytona/sdk'

const daytona = new Daytona()
const sandbox = await daytona.v2.create()

const handle = await sandbox.pty.start(undefined, { name: 'dev-shell' })

// Names are unique among live processes and resolve wherever an ID is accepted
const info = await sandbox.process.get('dev-shell')
```

<Callout type="note" title="Notes">
- The request requires the `write:sandboxes` permission.
- A name has 1 to 128 characters of letters, digits, `.`, `_`, and `-`, and cannot be `.` or `..`.
- The names `entrypoint` and `dockerd` are reserved for system processes.
</Callout>

</TabItem>
<TabItem label="API" icon="seti:json">

```bash
# A second live process with the same name is rejected with 409 NAME_CONFLICT
curl '{toolboxProxyUrl}/{sandboxId}/processes' \
  --request POST \
  --header 'Content-Type: application/json' \
  --header 'Authorization: Bearer YOUR_API_KEY' \
  --data '{"kind": "pty", "name": "dev-shell", "stdin": "open"}'

# The name works as an alias for the process ID on every /processes/{id} route
curl '{toolboxProxyUrl}/{sandboxId}/processes/dev-shell' \
  --header 'Authorization: Bearer YOUR_API_KEY'
```

<Callout type="note" title="Notes">
- The request requires the `write:sandboxes` permission.
- A name has 1 to 128 characters of letters, digits, `.`, `_`, and `-`, and cannot be `.` or `..`.
- The names `entrypoint` and `dockerd` are reserved for system processes.
</Callout>

</TabItem>
</Tabs>

## Configure shell, user, and timeout [#configure-shell-user-and-timeout]

Configure the shell, the user, and the timeout of a PTY session. A shell that cannot be found or a user that does not exist fails the start. When the timeout elapses, the session is killed and reports that it timed out.

<Tabs syncKey="language">
<TabItem label="Python" icon="seti:python">

```python
from daytona import Daytona

daytona = Daytona()
sandbox = daytona.v2.create()

handle = sandbox.pty.start(
    shell="/bin/bash",  # default: the user's login shell, then bash, zsh, sh
    user="root",  # run as another user
    execution_timeout=600,  # SIGKILL after 10 minutes; wait() reports reason "timed_out"
)
```

| **Parameter** | **Type** | **Description**                               |
| ------------- | -------- | --------------------------------------------- |
| `shell`       | str | Sets the shell, as a path or a name looked up on `PATH`. |
| `user`        | str | Sets the user the session runs as. Without `shell`, the session runs through the user's login shell, so a user whose shell is `nologin` cannot run commands. The user must exist in `/etc/passwd` of the sandbox. |
| `execution_timeout` | float | Sets how many seconds the session can run before Daytona kills it. |

</TabItem>
<TabItem label="TypeScript" icon="seti:typescript">

```typescript
import { Daytona } from '@daytona/sdk'

const daytona = new Daytona()
const sandbox = await daytona.v2.create()

const handle = await sandbox.pty.start(undefined, {
  shell: '/bin/bash', // default: the user's login shell, then bash, zsh, sh
  user: 'root', // run as another user
  executionTimeoutSeconds: 600, // SIGKILL after 10 minutes; wait() reports reason 'timed_out'
})
```

| **Parameter** | **Type** | **Description**                               |
| ------------- | -------- | --------------------------------------------- |
| `shell`       | string | Sets the shell, as a path or a name looked up on `PATH`. |
| `user`        | string | Sets the user the session runs as. Without `shell`, the session runs through the user's login shell, so a user whose shell is `nologin` cannot run commands. The user must exist in `/etc/passwd` of the sandbox. |
| `executionTimeoutSeconds` | number | Sets how many seconds the session can run before Daytona kills it. |

</TabItem>
<TabItem label="API" icon="seti:json">

```bash
curl '{toolboxProxyUrl}/{sandboxId}/processes' \
  --request POST \
  --header 'Content-Type: application/json' \
  --header 'Authorization: Bearer YOUR_API_KEY' \
  --data '{
  "kind": "pty",
  "shell": "/bin/bash",
  "user": "root",
  "timeoutMs": 600000,
  "stdin": "open"
}'
```

| **Field**   | **Type**  | **Description**                                                                                        |
| ----------- | --------- | ------------------------------------------------------------------------------------------------------ |
| `timeoutMs` | integer | Sets the session timeout in milliseconds. When the timeout elapses, the daemon sends `SIGKILL` and the record reports `reason: timed_out`. |

</TabItem>
</Tabs>

## Connect to PTY session [#connect-to-pty-session]

Connect to an existing PTY session by its ID. Connecting creates a handle without contacting the sandbox; the handle sends input to the session, reads its output, resizes it, and stops it. A live connection streams output produced after it opens; to see earlier output, replay it from a cursor.

<Tabs syncKey="language">
<TabItem label="Python" icon="seti:python">

```python
handle = sandbox.pty.connect(process_id)
handle.write_stdin('echo "Reconnected"\n')

# The Python SDK has no WebSocket attach. Use write_stdin() and
# stream_logs() instead.
```

</TabItem>
<TabItem label="TypeScript" icon="seti:typescript">

```typescript
const handle = sandbox.pty.connect(processId)
const connection = await handle.attach()

const decoder = new TextDecoder()
;(async () => {
  for await (const chunk of connection.readable) {
    process.stdout.write(decoder.decode(chunk))
  }
})()

connection.write('echo "Reconnected"\n')
```

<Callout type="note" title="Notes">
- The request requires the `write:sandboxes` permission.
- `attach()` opens a WebSocket and needs a global `WebSocket` in the runtime.
- Read earlier output with `logs()`.
- If the session has already exited, the connection closes without output.
</Callout>

</TabItem>
<TabItem label="CLI" icon="seti:shell">

```bash
daytona sandbox process attach {sandboxId} {processId}

# Replay output from a previous log cursor before the live stream
daytona sandbox process attach {sandboxId} {processId} --cursor {cursor}
```

| **Flag**   | **Type** | **Description**                                                 |
| ---------- | -------- | --------------------------------------------------------------- |
| `--cursor` | string | Replays output from a previous log cursor before the live stream. Without this flag, the command shows only output produced after the attach. |

</TabItem>
<TabItem label="API" icon="seti:json">

```bash
# WebSocket endpoint: binary frames carry PTY output,
# text or binary client messages are written to stdin.
# Authenticate with an Authorization: Bearer header or ?daytona_token= (browsers).
{toolboxWebSocketUrl}/{sandboxId}/processes/{processId}/attach

# Pass ?cursor= to replay output from a previous log cursor before the live stream.
# Close codes: 1008 "cursor expired" | "invalid cursor" | "slow consumer";
# a plain close when the process is already terminal (read scrollback from /logs).
{toolboxWebSocketUrl}/{sandboxId}/processes/{processId}/attach?cursor={cursor}
```

<Callout type="note" title="Notes">
- The request requires the `write:sandboxes` permission.
- `{toolboxWebSocketUrl}` is the `toolbox_proxy_url` field of the [sandbox](/docs/sandboxes#get-a-sandbox), with `wss://` in place of `https://`.
</Callout>

</TabItem>
</Tabs>

## List PTY sessions [#list-pty-sessions]

List PTY sessions, including retained exited ones; check each session's state.

<Tabs syncKey="language">
<TabItem label="Python" icon="seti:python">

```python
from daytona import Daytona

daytona = Daytona()
sandbox = daytona.v2.create()

processes = sandbox.pty.list()
for info in processes.items:
    print(f"PTY: {info.id} ({info.state})")
    if info.terminal:
        print(f"  Size: {info.terminal.cols}x{info.terminal.rows}")
    print(f"  Working directory: {info.cwd}")
```

</TabItem>
<TabItem label="TypeScript" icon="seti:typescript">

```typescript
import { Daytona } from '@daytona/sdk'

const daytona = new Daytona()
const sandbox = await daytona.v2.create()

const processes = await sandbox.pty.list()
for (const info of processes.items) {
  console.log(`PTY: ${info.id} (${info.state})`)
  if (info.terminal) {
    console.log(`  Size: ${info.terminal.cols}x${info.terminal.rows}`)
  }
  console.log(`  Working directory: ${info.cwd}`)
}
```

</TabItem>
<TabItem label="CLI" icon="seti:shell">

```bash
daytona sandbox process list {sandboxId} --kind pty
```

</TabItem>
<TabItem label="API" icon="seti:json">

```bash
curl '{toolboxProxyUrl}/{sandboxId}/processes?kind=pty' \
  --header 'Authorization: Bearer YOUR_API_KEY'
```

</TabItem>
</Tabs>

## Get PTY session info [#get-pty-session-info]

<Tabs syncKey="language">
<TabItem label="Python" icon="seti:python">

```python
info = handle.get()
print(f"PTY: {info.id}")
print(f"State: {info.state}")
print(f"PID: {info.pid}")
if info.terminal:
    print(f"Size: {info.terminal.cols}x{info.terminal.rows}")
print(f"Working directory: {info.cwd}")
if info.state == "terminal":
    print(f"Exit code: {info.exit_code}")
```

</TabItem>
<TabItem label="TypeScript" icon="seti:typescript">

```typescript
const info = await handle.get()
console.log(`PTY: ${info.id}`)
console.log(`State: ${info.state}`)
console.log(`PID: ${info.pid}`)
console.log(`Size: ${info.terminal?.cols}x${info.terminal?.rows}`)
console.log(`Working directory: ${info.cwd}`)
if (info.state === 'terminal') {
  console.log(`Exit code: ${info.exitCode}`)
}
```

</TabItem>
<TabItem label="CLI" icon="seti:shell">

```bash
daytona sandbox process get {sandboxId} {processId}
```

</TabItem>
<TabItem label="API" icon="seti:json">

```bash
curl '{toolboxProxyUrl}/{sandboxId}/processes/{processId}' \
  --header 'Authorization: Bearer YOUR_API_KEY'
```

</TabItem>
</Tabs>

## Kill PTY session [#kill-pty-session]

Stop a PTY session and remove it. Stopping sends a signal to the session's processes; removing deletes the session record and its output. An interactive shell ignores the default termination signal, so a session started without a command keeps running unless the signal escalates to a kill. Removing a session that is still running kills it first.

<Tabs syncKey="language">
<TabItem label="Python" icon="seti:python">

```python
# Sends SIGTERM, then SIGKILL after 5 seconds if the session is still running
handle.kill(escalate_after=5)

# Removes the process record and logs
handle.delete()
```

| **Parameter**    | **Type**     | **Description**                                                                                     |
| ---------------- | ------------ | --------------------------------------------------------------------------------------------------- |
| `signal`         | SignalName | Sets the signal that `kill()` sends: `TERM`, `KILL`, `INT`, `HUP`, `QUIT`, `USR1`, or `USR2`. The default is `"TERM"`. |
| `escalate_after` | float      | Sends `escalate_to` if the session is still running after this delay. The delay is in seconds, at most `600`. |
| `escalate_to`    | SignalName | Sets the signal sent after `escalate_after`. The default is `"KILL"`. |

</TabItem>
<TabItem label="TypeScript" icon="seti:typescript">

```typescript
// Sends SIGTERM, then SIGKILL after 5 seconds if the session is still running
await handle.kill({ escalateAfterSeconds: 5 })

// Removes the process record and logs
await handle.delete()
```

| **Parameter**   | **Type**        | **Description**                                                                                             |
| --------------- | --------------- | ----------------------------------------------------------------------------------------------------------- |
| `signal`        | ProcessSignal | Sets the signal that `kill()` sends: `'TERM'`, `'KILL'`, `'INT'`, `'HUP'`, `'QUIT'`, `'USR1'`, or `'USR2'`. |
| `escalateAfterSeconds` | number   | Sends `escalateTo` if the session is still running after this delay. The delay is in seconds, at most `600`. |
| `escalateTo`    | ProcessSignal | Sets the signal sent after `escalateAfterSeconds`. The default is `'KILL'`. |

</TabItem>
<TabItem label="CLI" icon="seti:shell">

```bash
# Send SIGTERM, then SIGKILL after 5 seconds if the session is still running
daytona sandbox process signal {sandboxId} {processId} TERM --escalate-after 5s

# Or send SIGKILL; the CLI cannot remove the process record and logs
daytona sandbox process kill {sandboxId} {processId}
```

| **Flag**           | **Type** | **Description**                                                                                     |
| ------------------ | -------- | --------------------------------------------------------------------------------------------------- |
| `--escalate-after` | duration | Sends `--escalate-to` if the session is still running after this delay. The delay is a duration such as `5s`, at most `10m`. The flag is available only on `process signal`. |
| `--escalate-to`    | string | Sets the second signal, sent after `--escalate-after`. The default is `KILL`. The flag is available only on `process signal`. |

<Callout type="note" title="Notes">
- The request requires the `write:sandboxes` permission.
- Signal names have no `SIG` prefix, and the accepted names are `TERM`, `KILL`, `INT`, `HUP`, `QUIT`, `USR1`, and `USR2`.
</Callout>

</TabItem>
<TabItem label="API" icon="seti:json">

```bash
# Send a signal
curl '{toolboxProxyUrl}/{sandboxId}/processes/{processId}/signals' \
  --request POST \
  --header 'Content-Type: application/json' \
  --header 'Authorization: Bearer YOUR_API_KEY' \
  --data '{"signal": "SIGTERM"}'

# Remove the process record and logs
curl '{toolboxProxyUrl}/{sandboxId}/processes/{processId}' \
  --request DELETE \
  --header 'Authorization: Bearer YOUR_API_KEY'
```

| **Field**         | **Type**  | **Description**                                                                                   |
| ----------------- | --------- | ------------------------------------------------------------------------------------------------- |
| `signal`          | string  | Sets the signal to send, with the `SIG` prefix. An empty value sends `SIGTERM`. |
| `escalateAfterMs` | integer | Sends `escalateTo` if the session is still running after this delay. The delay is at most `600000`. |
| `escalateTo`      | string  | Sets the signal sent after `escalateAfterMs`, with the `SIG` prefix. The default is `SIGKILL`. |

</TabItem>
</Tabs>

### Delete a PTY session [#delete-a-pty-session]

Delete a PTY session in one step: a running session gets a termination signal, then the session record and its output are removed. Deleting a non-PTY process fails and leaves it running.

<Tabs syncKey="language">
<TabItem label="Python" icon="seti:python">

```python
sandbox.pty.delete(handle.id)
```

</TabItem>
<TabItem label="TypeScript" icon="seti:typescript">

```typescript
await sandbox.pty.delete(handle.id)
```

</TabItem>
</Tabs>

## Resize PTY session [#resize-pty-session]

Resize the terminal of a running PTY session. The program in the terminal receives a window size change signal and can redraw at the new size. The session record reports the new size.

<Tabs syncKey="language">
<TabItem label="Python" icon="seti:python">

```python
handle.resize(150, 40)
```

<Callout type="note" title="Notes">
- The request requires the `write:sandboxes` permission.
- `cols` and `rows` must be at least `1`.
- You can resize only PTY sessions.
</Callout>

</TabItem>
<TabItem label="TypeScript" icon="seti:typescript">

```typescript
await handle.resize(150, 40)
```

<Callout type="note" title="Notes">
- The request requires the `write:sandboxes` permission.
- `cols` and `rows` must be at least `1`.
- You can resize only PTY sessions.
</Callout>

</TabItem>
<TabItem label="API" icon="seti:json">

```bash
curl '{toolboxProxyUrl}/{sandboxId}/processes/{processId}/resize' \
  --request POST \
  --header 'Content-Type: application/json' \
  --header 'Authorization: Bearer YOUR_API_KEY' \
  --data '{"cols": 150, "rows": 40}'
```

<Callout type="note" title="Notes">
- The request requires the `write:sandboxes` permission.
- `cols` and `rows` must be at least `1`.
- You can resize only PTY sessions.
</Callout>

</TabItem>
</Tabs>

## Send input [#send-input]

Send input to a PTY session. Input reaches the terminal as if typed, so end each command with a newline. Control keys are bytes: send Ctrl+C as 0x03 and Ctrl+D as 0x04. A terminal has no separate end of input; to end input, send Ctrl+D.

<Tabs syncKey="language">
<TabItem label="Python" icon="seti:python">

```python
handle.write_stdin("ls -la\n")
```

| **Parameter** | **Type**            | **Description**                                         |
| ------------- | ------------------- | ------------------------------------------------------- |
| `data`        | Union\[str, bytes\] | Sets the input to send, as `str` or `bytes`. Bytes are sent base64 encoded. This parameter is required. |

</TabItem>
<TabItem label="TypeScript" icon="seti:typescript">

```typescript
await handle.writeStdin('ls -la\n')
```

| **Parameter** | **Type**                  | **Description**                                                       |
| ------------- | ------------------------- | --------------------------------------------------------------------- |
| `data`        | string \| Uint8Array    | Sets the input to send, as a `string` or a `Uint8Array`. A `Uint8Array` is sent base64 encoded. This parameter is required. |

</TabItem>
<TabItem label="API" icon="seti:json">

```bash
curl '{toolboxProxyUrl}/{sandboxId}/processes/{processId}/stdin' \
  --request POST \
  --header 'Content-Type: application/json' \
  --header 'Authorization: Bearer YOUR_API_KEY' \
  --data '{"data": "ls -la\n"}'
```

| **Field**  | **Type** | **Description**                    |
| ---------- | -------- | ---------------------------------- |
| `encoding` | string | Sets the input encoding, `text` or `base64`. The default is `text`. |

</TabItem>
</Tabs>

## Read output [#read-output]

Read one page of PTY output. A page holds up to 1,000 frames by default. To read past the first page, continue from a cursor as shown in [replay output from a cursor](#replay-output-from-a-cursor).

<Tabs syncKey="language">
<TabItem label="Python" icon="seti:python">

```python
page = handle.logs()
for frame in page.items:
    print(frame.data, end="")
```

| **Parameter** | **Type**      | **Description**                                                                                   |
| ------------- | ------------- | ------------------------------------------------------------------------------------------------- |
| `limit`       | int         | Sets the page size. The daemon clamps the page size to `1` to `5000`. The default is `1000`. |
| `encoding`    | LogEncoding | Sets the output encoding. In text encoding, bytes that are not valid UTF-8 are replaced. Pass `"base64"` to get each frame's `data` as a standard base64 string of the raw bytes. The default is `"text"`. |

<Callout type="note" title="Notes">
- The request requires the `write:sandboxes` permission.
- A read without a cursor starts at the first frame the session wrote, so it fails once retention evicts that frame; read from the oldest available cursor in the error instead.
</Callout>

</TabItem>
<TabItem label="TypeScript" icon="seti:typescript">

```typescript
const page = await handle.logs()
for (const frame of page.items) {
  process.stdout.write(frame.data)
}
```

| **Parameter** | **Type** | **Description**                                                                                     |
| ------------- | -------- | --------------------------------------------------------------------------------------------------- |
| `limit`       | number | Sets the page size. The daemon clamps the page size to `1` to `5000`. The default is `1000`. |
| `encoding`    | string | Sets the output encoding. In text encoding, bytes that are not valid UTF-8 are replaced. Pass `'base64'` to get each frame's `data` as a standard base64 string of the raw bytes. The default is `'text'`. |

<Callout type="note" title="Notes">
- The request requires the `write:sandboxes` permission.
- A read without a cursor starts at the first frame the session wrote, so it fails once retention evicts that frame; read from the oldest available cursor in the error instead.
</Callout>

</TabItem>
<TabItem label="CLI" icon="seti:shell">

```bash
daytona sandbox process logs {sandboxId} {processId}
```

<Callout type="note" title="Notes">
- The request requires the `write:sandboxes` permission.
- A read without a cursor starts at the first frame the session wrote, so it fails once retention evicts that frame; read from the oldest available cursor in the error instead.
</Callout>

</TabItem>
<TabItem label="API" icon="seti:json">

```bash
curl '{toolboxProxyUrl}/{sandboxId}/processes/{processId}/logs' \
  --header 'Authorization: Bearer YOUR_API_KEY'
```

| **Field**  | **Type**  | **Description**                                                                                                  |
| ---------- | --------- | ---------------------------------------------------------------------------------------------------------------- |
| `limit`    | integer | Sets the page size. The value is clamped to `1` to `5000`. The default is `1000`. |
| `encoding` | string  | Sets the output encoding, `text` or `base64`. Text replaces bytes that are not valid UTF-8. Base64 output is standard base64 with padding. The default is `text`. |

<Callout type="note" title="Notes">
- The request requires the `write:sandboxes` permission.
- A read without a cursor starts at the first frame the session wrote, so it fails once retention evicts that frame; read from the `firstAvailableCursor` of the error instead.
</Callout>

</TabItem>
</Tabs>

## Replay output from a cursor [#replay-output-from-a-cursor]

Replay PTY output from a cursor. Every output frame carries a cursor, and a read with a cursor returns the frames after it, so a client that saved the last cursor it processed can resume without gaps or duplicates. Retention can evict old output; a cursor that points into evicted output fails, and the error carries the oldest cursor still available. See [log retention](/docs/log-streaming#log-retention).

<Tabs syncKey="language">
<TabItem label="Python" icon="seti:python">

```python
# Every frame carries a cursor; reading resumes strictly after it. An empty page means the read has caught up
cursor = last_cursor
while True:
    page = handle.logs(cursor=cursor)
    if not page.items:
        break
    for frame in page.items:
        print(frame.data, end="")
        cursor = frame.cursor
```

</TabItem>
<TabItem label="TypeScript" icon="seti:typescript">

```typescript
// Every frame carries a cursor; reading resumes strictly after it. An empty page means the read has caught up
let cursor = lastCursor
for (;;) {
  const page = await handle.logs({ cursor })
  if (page.items.length === 0) break
  for (const frame of page.items) {
    process.stdout.write(frame.data)
    cursor = frame.cursor
  }
}
```

</TabItem>
<TabItem label="CLI" icon="seti:shell">

```bash
daytona sandbox process logs {sandboxId} {processId} --cursor {cursor}
```

</TabItem>
<TabItem label="API" icon="seti:json">

```bash
# An evicted cursor returns 409 CURSOR_EXPIRED with firstAvailableCursor
curl '{toolboxProxyUrl}/{sandboxId}/processes/{processId}/logs?cursor={cursor}' \
  --header 'Authorization: Bearer YOUR_API_KEY'
```

</TabItem>
</Tabs>

## Wait for completion [#wait-for-completion]

Wait for a PTY session to exit. The result holds the exit code, the ending signal if any, and a reason: exited, killed by a signal, timed out, failed to start, or lost. If the wait time elapses first, the result reports a timeout with no exit code, and the session keeps running.

<Tabs syncKey="language">
<TabItem label="Python" icon="seti:python">

```python
result = handle.wait(timeout=30)
print(result.reason, result.exit_code)
```

| **Parameter** | **Type** | **Description**                                                   |
| ------------- | -------- | ----------------------------------------------------------------- |
| `timeout`     | float  | Sets how long to wait, in seconds. Omit it or pass `0` to wait until the session exits. |

</TabItem>
<TabItem label="TypeScript" icon="seti:typescript">

```typescript
const result = await handle.wait({ timeoutSeconds: 30 })
console.log(result.reason, result.exitCode)
```

| **Parameter** | **Type** | **Description**                                                       |
| ------------- | -------- | --------------------------------------------------------------------- |
| `timeoutSeconds` | number | Sets how long to wait, in seconds. Omit it or pass `0` to wait until the session exits. |

</TabItem>
<TabItem label="CLI" icon="seti:shell">

```bash
daytona sandbox process wait {sandboxId} {processId} --timeout 30s
```

</TabItem>
<TabItem label="API" icon="seti:json">

```bash
curl '{toolboxProxyUrl}/{sandboxId}/processes/{processId}/wait?timeoutMs=30000' \
  --header 'Authorization: Bearer YOUR_API_KEY'
```

| **Field**   | **Type**  | **Description**                                      |
| ----------- | --------- | ---------------------------------------------------- |
| `timeoutMs` | integer | Sets how long to wait, in milliseconds. `0` waits until the session exits. The default is `60000`. |

The response has the following fields:

| **Field**  | **Description**                                                                                                  |
| ---------- | ---------------------------------------------------------------------------------------------------------------- |
| `exitCode` | The exit code of the session. Omitted when the wait time elapses first.                                          |
| `reason`   | Why the session ended: `exited`, `signaled`, `timed_out`, `lost`, or `failed`. `timed_out` when the wait time elapses first. |
| `signal`   | The signal name, such as `SIGKILL`. Omitted when none applies.                                                   |

</TabItem>
</Tabs>

## Interactive commands [#interactive-commands]

Run several commands in one interactive shell. The shell keeps its state between commands, such as the working directory and variables. Read output while you send input; the output stream ends after the shell exits and all of its output is delivered.

<Tabs syncKey="language">
<TabItem label="Python" icon="seti:python">

```python
import threading

from daytona import Daytona

daytona = Daytona()
sandbox = daytona.v2.create()

handle = sandbox.pty.start()

# Print output in the background
def pump():
    for event in handle.stream_logs():
        if event.type == "log":
            print(event.frame.data, end="")

reader = threading.Thread(target=pump, daemon=True)
reader.start()

for command in [
    "pwd",
    "ls -la",
    "echo 'Hello from PTY'",
    "python3 -c 'print(2 + 2)'",
    "exit",
]:
    handle.write_stdin(command + "\n")

result = handle.wait()
reader.join(timeout=5)
print(f"\nShell exited with code: {result.exit_code}")
handle.delete()
```

</TabItem>
<TabItem label="TypeScript" icon="seti:typescript">

```typescript
import { Daytona } from '@daytona/sdk'

const daytona = new Daytona()
const sandbox = await daytona.v2.create()

const { handle, connection } = await sandbox.pty.attach()

const decoder = new TextDecoder()
const reader = (async () => {
  for await (const chunk of connection.readable) {
    process.stdout.write(decoder.decode(chunk))
  }
})()

for (const command of [
  'pwd',
  'ls -la',
  "echo 'Hello from PTY'",
  "python3 -c 'print(2 + 2)'",
  'exit',
]) {
  connection.write(command + '\n')
}

const result = await handle.wait()
await reader
if (result.reason === 'exited') {
  console.log(`\nShell exited with code: ${result.exitCode}`)
}
await handle.delete()
```

<Callout type="note" title="Notes">
- The request requires the `write:sandboxes` permission.
- The connection streams output written after it opens, so the shell's first prompt can be missing; read it with `handle.logs()`.
</Callout>

</TabItem>
</Tabs>

## Long-running processes [#long-running-processes]

Interrupt a running command from the terminal. Ctrl+C sent as input goes through the terminal, which interrupts the foreground command; the shell itself keeps running. Signaling the session instead targets the whole session, including the shell. See [kill PTY session](#kill-pty-session).

<Tabs syncKey="language">
<TabItem label="Python" icon="seti:python">

```python
import time

from daytona import Daytona

daytona = Daytona()
sandbox = daytona.v2.create()

handle = sandbox.pty.start()
handle.write_stdin("for i in $(seq 1 100); do echo \"Line $i\"; sleep 1; done\n")

time.sleep(5)
handle.write_stdin("\x03")  # Ctrl+C
handle.write_stdin("exit\n")

result = handle.wait()
for frame in handle.logs().items:
    print(frame.data, end="")
print(f"\nExit code: {result.exit_code}")
handle.delete()
```

</TabItem>
<TabItem label="TypeScript" icon="seti:typescript">

```typescript
import { Daytona } from '@daytona/sdk'

const daytona = new Daytona()
const sandbox = await daytona.v2.create()

const { handle, connection } = await sandbox.pty.attach()

const decoder = new TextDecoder()
;(async () => {
  for await (const chunk of connection.readable) {
    process.stdout.write(decoder.decode(chunk))
  }
})()

connection.write('for i in $(seq 1 100); do echo "Line $i"; sleep 1; done\n')

await new Promise((resolve) => setTimeout(resolve, 5000))
connection.write('\x03') // Ctrl+C
connection.write('exit\n')

const result = await handle.wait()
if (result.reason === 'exited') {
  console.log(`\nExit code: ${result.exitCode}`)
}
await handle.delete()
```

</TabItem>
</Tabs>

## Resource management [#resource-management]

Remove a PTY session when you no longer need it. The SDKs keep an exited session's record and output until it is deleted, so delete it in a final block to remove it even when a step fails.

<Tabs syncKey="language">
<TabItem label="Python" icon="seti:python">

```python
from daytona import Daytona

daytona = Daytona()
sandbox = daytona.v2.create()

handle = sandbox.pty.start()
try:
    handle.write_stdin("echo hello\n")
    handle.write_stdin("exit\n")
    handle.wait(timeout=10)
finally:
    handle.delete()
```

</TabItem>
<TabItem label="TypeScript" icon="seti:typescript">

```typescript
import { Daytona } from '@daytona/sdk'

const daytona = new Daytona()
const sandbox = await daytona.v2.create()

const { handle, connection } = await sandbox.pty.attach()
try {
  connection.write('echo hello\n')
  connection.write('exit\n')
  await handle.wait({ timeoutSeconds: 10 })
} finally {
  connection.close()
  await handle.delete()
}
```

</TabItem>
</Tabs>

## Permissions [#permissions]

A PTY session runs through the toolbox of a sandbox. Access is governed by one project permission, which you can grant to an [organization role](/docs/organizations#roles) or an [API key](/docs/api-keys).

A [sandbox access token](/docs/sandboxes#create-an-access-token) with the toolbox:exec operation grants the same access to a single sandbox, without an API key. The sandbox read permission alone does not grant toolbox access.

| **Permission**        | **Grants**                                       |
| --------------------- | ------------------------------------------------ |
| **`write:sandboxes`** | Start, connect to, resize, and kill PTY sessions |
