# Driving terminals

Create a pseudo-terminal on the server, attach to it over a websocket, and render it wherever your interface lives.

## Three ways to run a command

Before reaching for a PTY, check that you want one — two lighter options cover most cases.

| You want | Use | Why |
| --- | --- | --- |
| The agent to run something and see the result | `session.shell` | Recorded in the transcript, so the next turn sees it. |
| A one-off command from your own code | Your own runtime | The API adds nothing. |
| An interactive terminal a human types into | PTY | Real tty: resizing, control characters, curses apps. |

A PTY is what the TUI's terminal pane and the mobile app's terminal view are built on. If you are building an interface, this is the one.

## Create a terminal

Every field is optional. With no `command` you get the default shell, which is usually what an interactive terminal wants.

ts 

1

2

3

4

5

6

7

8

```
const { data: pty, error } = await client.pty.create({
  title: "build",
  cwd: process.cwd(),
  command: "bun",
  args: ["test", "--watch"],
  env: { FORCE_COLOR: "1" },
})
if (!pty) throw error
```

Terminals outlive the request that made them

A PTY belongs to the server, not to your connection. Detaching and reattaching later picks the same session back up — which is how the mobile app resumes a terminal after the phone locks.

## Attach to it

`GET /pty/{ptyID}/connect` is a websocket upgrade, not a JSON endpoint. The generated client does not wrap it — a schema cannot describe a bidirectional socket — so you open it yourself.

ts 

1

2

3

4

5

6

7

8

9

10

11

12

13

```
const url = new URL(`/pty/${pty.id}/connect`, "http://127.0.0.1:4096")
url.protocol = url.protocol.replace("http", "ws")
url.searchParams.set("directory", process.cwd())
if (token) url.searchParams.set("token", token)

const socket = new WebSocket(url)

socket.addEventListener("message", (event) => {
  process.stdout.write(typeof event.data === "string" ? event.data : "")
})

// input goes the other way down the same socket
process.stdin.on("data", (chunk) => socket.send(chunk.toString()))
```

A websocket cannot send an Authorization header

Browsers do not let you set headers on a `WebSocket`. That is why the API accepts the credential as `?token=` — the same reason `EventSource` needs it for the event feed.

## Resizing

Resizing is an ordinary HTTP call, not a socket message. Programs inside the terminal get `SIGWINCH` and re-wrap; without it, anything full-screen renders at the wrong size.

ts 

1

2

3

4

```
await client.pty.update({
  ptyID: pty.id,
  size: { rows: 40, cols: 120 },
})
```

Send it once on attach and again whenever the container changes — debounced, since a drag produces a resize per frame.

## Listing and closing

ts 

1

2

3

4

5

```
const { data: terminals } = await client.pty.list()
const { data: one } = await client.pty.get({ ptyID: pty.id })

await client.pty.update({ ptyID: pty.id, title: "tests (watching)" })
await client.pty.remove({ ptyID: pty.id })
```

`list` is how an interface rebuilds its tab bar after a reload, since the terminals are still running on the server.

## Injecting environment

Two layers add environment variables, and they compose. `env` on create is the per-terminal layer. The `shell.env` plugin hook is the global one, applied to every PTY the server makes.

.nikcli/plugins/shell-env.ts

ts 

1

2

3

4

5

6

7

8

```
import type { Plugin } from "@nikcli-ai/plugin"

export const ShellEnv: Plugin = async () => ({
  async "shell.env"(input, output) {
    output.env.NPM_TOKEN = process.env.CI_NPM_TOKEN ?? ""
    if (input.cwd.includes("/infra")) output.env.AWS_PROFILE = "staging"
  },
})
```

Plugin values are merged over the caller's, and then terminal invariants such as `TERM` and `NIKCLI_TERMINAL` are re-applied — so a plugin cannot break the terminal by overwriting them.

## A terminal in the browser

The whole integration is: create, attach, pipe both directions, report size. This is `xterm.js`, but nothing here is specific to it.

terminal.ts

ts 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39

40

41

42

43

44

45

46

47

48

49

50

```
import { Terminal } from "@xterm/xterm"
import { FitAddon } from "@xterm/addon-fit"
import { createNikcliClient } from "@nikcli-ai/sdk/httpapi"

const BASE = "http://127.0.0.1:4096"
const directory = "/Users/me/project"

const client = createNikcliClient({ baseUrl: BASE, directory })

const { data: pty, error } = await client.pty.create({ title: "shell", cwd: directory })
if (!pty) throw error

const term = new Terminal({ convertEol: true })
const fit = new FitAddon()
term.loadAddon(fit)
term.open(document.getElementById("terminal")!)
fit.fit()

const url = new URL(`/pty/${pty.id}/connect`, BASE)
url.protocol = url.protocol.replace("http", "ws")
url.searchParams.set("directory", directory)

const socket = new WebSocket(url)
socket.binaryType = "arraybuffer"

socket.addEventListener("open", () => {
  void client.pty.update({ ptyID: pty.id, size: { rows: term.rows, cols: term.cols } })
})

socket.addEventListener("message", (event) => {
  term.write(
    typeof event.data === "string" ? event.data : new Uint8Array(event.data as ArrayBuffer),
  )
})

term.onData((data) => socket.send(data))

// one resize call per settled layout, not one per frame
let pending: ReturnType<typeof setTimeout> | undefined
const onResize = () => {
  clearTimeout(pending)
  pending = setTimeout(() => {
    fit.fit()
    void client.pty.update({ ptyID: pty.id, size: { rows: term.rows, cols: term.cols } })
  }, 100)
}
window.addEventListener("resize", onResize)

// closing the tab should not kill the terminal — just detach
window.addEventListener("beforeunload", () => socket.close())
```

Closing the socket is not closing the terminal

`socket.close()` detaches; the process keeps running server-side. Call `pty.remove` when you actually mean to end it — otherwise a reload leaves an orphan behind on every visit.

## Failure modes

| Symptom | Cause |
| --- | --- |
| Socket closes immediately | Auth: a websocket cannot send headers, so pass `?token=`. |
| Output wraps wrongly, full-screen apps corrupt | Size never reported. Call `pty.update` on attach. |
| Terminals pile up | Detaching instead of removing. `pty.list` will show them. |
| Wrong working directory | No `cwd`, so it inherited the server's. |
| No colour | The program sees a non-tty downstream; try `FORCE_COLOR=1`. |
| 404 on connect | Right id, wrong instance — the `directory` selector must match the one that created it. |
| Garbled output | Binary frames decoded as UTF-8 strings mid-sequence; write the bytes through. |

## Related

-   [Terminals API Every PTY endpoint and payload.](/docs/api/pty)
-   [Terminal Control The engine behind terminal automation.](/docs/terminal-control)
-   [Plugin recipes The shell.env recipe, in full.](/docs/guides/plugin-recipes)
-   [Using the API The client this guide assumes.](/docs/guides/api)

---

Source: https://nikcli-ai.dev/docs/guides/terminals
