Skip to content
nikcli
Docs
Documentation

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 wantUseWhy
The agent to run something and see the resultsession.shellRecorded in the transcript, so the next turn sees it.
A one-off command from your own codeYour own runtimeThe API adds nothing.
An interactive terminal a human types intoPTYReal 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
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
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
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
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
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
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

SymptomCause
Socket closes immediatelyAuth: a websocket cannot send headers, so pass ?token=.
Output wraps wrongly, full-screen apps corruptSize never reported. Call pty.update on attach.
Terminals pile upDetaching instead of removing. pty.list will show them.
Wrong working directoryNo cwd, so it inherited the server's.
No colourThe program sees a non-tty downstream; try FORCE_COLOR=1.
404 on connectRight id, wrong instance — the directory selector must match the one that created it.
Garbled outputBinary frames decoded as UTF-8 strings mid-sequence; write the bytes through.