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.
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 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.
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()))
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.
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
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.
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.
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()) 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. |