Open Source · TypeScript · Node 18+ & Bun

Terminals in the browser.
Reattached.

PtyKit serves interactive shell sessions over a single WebSocket — with collaborative rooms, serialized-scrollback reattach that loses zero bytes, and a resilient client. Bring your own auth.

quick-start.ts
import { PtyKit, createPtyKitServer } from '@myrialabs/ptykit';

const manager = new PtyKit();
const server = createPtyKitServer(manager, {
  path: '/pty',
});
Bun.serve({
  port: 3000,
  fetch: server.fetch,
  websocket: server.websocket,
});
import { mountTerminal } from '@myrialabs/ptykit/client';

const url = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/pty`;

await mountTerminal(
  document.getElementById('term'),
  {
    url,
    namespace: 'demo',
    sessionId: 'terminal-1',
    create: true,
    onStatus: (s) => {
      document.getElementById('status').textContent = s;
    },
  },
);

Everything a terminal needs
over the wire.

The hard parts of streaming a real PTY to the browser — solved and typed.

WebSocket only
One multiplexed control + data channel. No SSE, no transport option, no polling — a single, audited path.
Collaborative rooms
Output broadcasts to a room (default = namespace). N clients attach to one session and watch the same live terminal.
Reattach, zero loss
Scrollback lives in a headless xterm and replays as a single serialized frame — surviving refresh and disconnect with no double output.
Auto-detected backend
bun-pty on Bun, node-pty on Node. Both optional and lazily loaded — you never build the one you don't use.
Resilient client
Reconnect with exponential backoff, heal-reconnect for "open but dead" sockets, and idempotency-aware resend — all on by default.
Bring your own auth
An authorize hook enforces namespace access with anti-hijack ownership checks. The library ships no auth of its own.

From zero to a live terminal,
a few lines at a time

One call, a full terminal

mountTerminal skips the xterm boilerplate entirely. Give it a container and a url — it creates the terminal, fits it, opens the session, and wires output ⟶ input. The server mounts createPtyKitServer onto any HTTP server.

  • Creates Terminal + FitAddon internally — zero setup
  • Server builds the browser bundle on the fly with Bun.build
  • onStatus drives a connection-state indicator in your UI
  • fontSize, fitDebounceMs, and more — all configurable
basic
import { PtyKit, createPtyKitServer } from '@myrialabs/ptykit';

const manager = new PtyKit({ scrollback: 5000 });
const server = createPtyKitServer(manager, {
  path: '/pty',
});

Bun.serve({
  port: 8781,
  fetch: server.fetch,
  websocket: server.websocket,
});
import { mountTerminal } from '@myrialabs/ptykit/client';

const wsUrl = `ws://${location.host}/pty`;

await mountTerminal(
  document.getElementById('screen'),
  {
    url: wsUrl,
    namespace: 'demo',
    sessionId: 'demo-terminal-1',
    create: true,
    fontSize: 13,
    onStatus: (s) => {
      document.getElementById('status').textContent = s;
    },
  },
);

Manual control with PtyKitClient

When mountTerminal is too high-level, drop down to PtyKitClient directly. Create sessions, attach to existing ones, pipe data programmatically — ideal for tests, automation, or custom UIs that need full control over the connection lifecycle.

  • .create() spawns a shell session server-side
  • .attach() replays scrollback with zero data loss
  • .disconnect() / write() / onData() — full lifecycle
  • Works in Node, Bun, or the browser
manual.ts
import { PtyKit, createPtyKitServer } from '@myrialabs/ptykit';

const manager = new PtyKit();
const server = createPtyKitServer(manager, {
  path: '/pty',
});
const bun = Bun.serve({
  port: 0,
  fetch: server.fetch,
  websocket: server.websocket,
});
import { PtyKitClient } from '@myrialabs/ptykit/client';

const url = `ws://localhost:${port}/pty`;

// First client: create session, run command, then disconnect.
const first = new PtyKitClient({
  url, namespace: 'demo',
});
const s1 = await first.create({
  sessionId: 'demo-1',
});
s1.write('echo "ran BEFORE refresh"\r');
first.disconnect();

// Second client: attach to SAME session — scrollback replays.
const second = new PtyKitClient({
  url, namespace: 'demo',
});
const s2 = await second.attach('demo-1');
s2.onData((c) => process.stdout.write(c));
s2.write('echo "ran AFTER reattaching"\r');

mountTerminal × 2 = collaboration

Two mountTerminal calls targeting the same session are all it takes. Output broadcasts to a room (default = the namespace) so both viewers see every keystroke and its result — one shell, N screens.

  • Same sessionId = same shell, shared output
  • mountTerminal handles xterm, fit, and reconnect
  • Serialized reattach is unicast; existing viewers aren't repainted
  • Type in either terminal — everyone sees it
collaborative.ts
import { PtyKit, createPtyKitServer } from '@myrialabs/ptykit';

const manager = new PtyKit();
const server = createPtyKitServer(manager, {
  path: '/pty',
});
const bun = Bun.serve({
  port: 0,
  fetch: server.fetch,
  websocket: server.websocket,
});
import { mountTerminal } from '@myrialabs/ptykit/client';

const wsUrl = `ws://${location.host}/pty`;

// Alice's terminal — creates the session
mountTerminal(
  document.getElementById('alice-term'),
  {
    url: wsUrl,
    namespace: 'team',
    sessionId: 'team-terminal-1',
    create: true,
  },
);

// Bob joins — same sessionId = same shell
mountTerminal(
  document.getElementById('bob-term'),
  {
    url: wsUrl,
    namespace: 'team',
    sessionId: 'team-terminal-1',
    create: true,
  },
);

Custom shell, cwd & environment

Configure which shell to launch, where to start, and what the child process sees. env.sanitize strips runtime pollution (Bun/npm/Vite variables), env.inject adds your own. The manager is transport-agnostic — use it with or without a server.

  • shell — force /bin/sh instead of $SHELL
  • cwd — start the session in any directory
  • env.sanitize removes Bun/npm/Vite pollution
  • env.inject adds your custom variables
custom-shell-env.ts
import { PtyKit } from '@myrialabs/ptykit';

const manager = new PtyKit({
  env: {
    sanitize: true,
    inject: { MY_VAR: 'hello' },
  },
});

const session = await manager.createSession({
  sessionId: 'env-1',
  namespace: 'local',
  shell: '/bin/sh',
  cwd: '/tmp',
  cols: 100,
  rows: 30,
});

session.addDataListener((chunk) =>
  process.stdout.write(chunk));
session.write('pwd\r');                       // → /tmp
session.write('echo "$MY_VAR"\r');             // → hello
session.write('echo "${npm_cache:-stripped}"\r');

Up and running in a minute.

One package, three entry points. The PTY backend is an optional dependency, resolved at runtime.

npm package/@myrialabs/ptykit
01
Install the package
$bun add @myrialabs/ptykit
02
Server — core engine + WebSocket transport
import { PtyKit, createPtyKitServer } from '@myrialabs/ptykit';
03
Browser — framework-agnostic client
import { PtyKitClient, attachFit, mountTerminal } from '@myrialabs/ptykit/client';
04
Svelte — the official component
import { PtyTerminal } from '@myrialabs/ptykit/svelte';

A small, typed surface.

TypeScript
Bun
Node 18+
WebSocket
xterm.js
bun-pty
node-pty
Svelte adapter

Wire up your terminal
in a few lines.

Open source, MIT licensed, and ready for production on Bun.