There are two distinct dimensions: embedding the frontend into your app, and embedding yas server into your own service.
@yas-run/react and @yas-run/solid are workspace-first. Both are thin wrappers over @yas-run/core's YasTerminalSurface. A YasWorkspace owns connections, each connection owns terminals, and each YasTerminal renders a terminal by ID.
import {
YasTerminal,
YasWorkspaceProvider,
useYasFocusedSession,
useYasSessions,
useYasWorkspace,
} from "@yas-run/react";
import { YasWorkspace } from "@yas-run/core";
import { useEffect, useMemo } from "react";
function EmbeddedYas({ wasm, passphrase }: { wasm: any; passphrase: string }) {
const workspace = useMemo(
() =>
new YasWorkspace({
wasm,
connections: [
{
id: "default",
transport: {
type: "websocket",
url: "wss://example.com/edge",
passphrase,
},
},
],
}),
[passphrase, wasm],
);
useEffect(() => () => workspace.dispose(), [workspace]);
return (
<YasWorkspaceProvider workspace={workspace}>
<TerminalScreen />
</YasWorkspaceProvider>
);
}
function TerminalScreen() {
const workspace = useYasWorkspace();
const sessions = useYasSessions();
const focusedSession = useYasFocusedSession();
useEffect(() => {
if (sessions.length > 0) return;
void workspace.createSession({
connectionId: "default",
rows: 24,
cols: 80,
});
}, [sessions.length, workspace]);
return (
<YasTerminal
sessionId={focusedSession?.id ?? null}
style={{ width: "100%", height: "100vh" }}
/>
);
}Read-only terminals use the same sizing behavior as writable terminals; readOnly only disables mutating input. Terminals resize the remote session to their available grid by default. The grid stays at native size in the top-left corner of its container, with excess content clipped while a resize is pending. Pass resizable={false} for passive views or read-only transports that must preserve the host dimensions; this keeps the same native-size presentation. Add fitWidth to opt a passive preview into scaling and centering to fill the container's width, as used by the right-sidebar and Cmd-K B switcher previews. Use a flex container to center the preview vertically within a fixed height.
| API | Purpose |
|---|---|
new YasWorkspace({ wasm, connections }) |
Create a workspace with one or more transports |
YasWorkspaceProvider |
Put the workspace, palette, and font settings in context |
useYasWorkspace() |
Get the imperative workspace object |
useYasWorkspaceState() |
Read the full reactive workspace snapshot |
useYasConnection(connectionId?) |
Read one connection snapshot |
useYasSessions() |
Read all terminals |
useYasFocusedSession() |
Read the currently focused terminal |
useYasWorkspaceConnection(workspace, id, transport) |
Manage a connection lifecycle with cleanup |
YasTerminal |
Render one terminal by sessionId |
import {
YasTerminal,
YasWorkspaceProvider,
createYasWorkspace,
createYasWorkspaceState,
createYasSessions,
useYasFocusedSession,
} from "@yas-run/solid";
import { YasWorkspace } from "@yas-run/core";
import { createSignal, onCleanup, createEffect } from "solid-js";
function EmbeddedYas(props: { wasm: any; passphrase: string }) {
const workspace = new YasWorkspace({
wasm: props.wasm,
connections: [
{
id: "default",
transport: {
type: "websocket",
url: "wss://example.com/yas",
passphrase: props.passphrase,
},
},
],
});
onCleanup(() => workspace.dispose());
return (
<YasWorkspaceProvider workspace={workspace}>
<TerminalScreen />
</YasWorkspaceProvider>
);
}
function TerminalScreen() {
const workspace = createYasWorkspace();
const sessions = createYasSessions();
const focusedSession = () => useYasFocusedSession(workspace);
createEffect(() => {
if (sessions().length > 0) return;
workspace.createSession({ connectionId: "default", rows: 24, cols: 80 });
});
return (
<YasTerminal
sessionId={focusedSession()?.id ?? null}
style={{ width: "100%", height: "100vh" }}
/>
);
}| API | Purpose |
|---|---|
new YasWorkspace({ wasm, connections }) |
Create a workspace with one or more transports |
YasWorkspaceProvider |
Put the workspace, palette, and font settings in context |
createYasWorkspace() |
Get the imperative workspace object from context |
createYasWorkspaceState(workspace?) |
Reactive signal tracking the workspace snapshot |
createYasSessions(workspace?) |
Reactive signal tracking all terminals |
useYasSession(workspace, sessionId) |
Look up a single terminal by ID (non-reactive) |
useYasFocusedSession(workspace) |
Look up the focused terminal (non-reactive) |
useYasConnection(workspace, sessionId) |
Look up a connection snapshot (non-reactive) |
createYasWorkspaceConnection(workspace, id, transport) |
Manage a connection lifecycle with onCleanup |
YasTerminal |
Render one terminal by sessionId |
YasSurfaceView renders a single Wayland surface from a terminal's compositor. The server encodes each surface as H.264 or AV1; the component decodes via WebCodecs and draws to a canvas.
By default the view participates in sizing its surface: the largest logical
rectangle that fits all active viewers is requested, subject to the application's
minimum/maximum size hints. The view is fully interactive. Pass
resizable={false} for a passive preview — a dock card, a switcher thumbnail —
that shares another view's stream.
Such a view is served a fixed downscale capped at a thumbnail cadence and takes
no input at all, so it is the wrong choice for anything the user clicks in.
zoom scales the surface independently of the pane's pixel size: zoomMode
"relative" (the default) multiplies the display's DPI by zoom, while
"exact" uses zoom as the absolute surface scale. Only resizable views drive
the scale.
Resizable views display decoded pixels 1:1 when codec rounding leaves the frame within two device pixels of its intended size on both axes. This avoids an extra browser resize on lower-DPI viewers sharing a HiDPI surface; a small gap at the right or bottom is preferable to filtering the text again. Adaptive frames with substantially lower resolution still fill their logical window size, anchored at the top-left. While a resize is in flight, the old frame keeps its intended scale and is clipped by the pane until the resized frame arrives. Only a committed application minimum forces uniform zoom-out; an oversized frame alone never does. Smaller windows stay at their intended scale rather than filling unused space. Minimum-forced zoom-out also expands the logical size offered to the application: if width forces a 360×780 pane to fit a 500px-wide window, it offers approximately 1083 logical pixels of height. These adjusted bounds are intersected across viewers, and reset when the application releases its minimum.
import { YasSurfaceView } from "@yas-run/react";
function AppWindow({
connectionId,
surfaceId,
}: {
connectionId: string;
surfaceId: number;
}) {
return (
<YasSurfaceView
connectionId={connectionId}
surfaceId={surfaceId}
style={{ width: 800, height: 600 }}
/>
);
}touchMode chooses how touchscreen contacts reach the app. The default,
"direct", forwards every contact to the app's own wl_touch, so pinch,
rotate, and multi-finger gestures belong to the app. Set "pointer" to opt out
and use YAS's compatibility gestures: tap to click, one-finger drag to scroll,
and long-press for right-click. A server without multitouch support
automatically keeps the pointer mapping. The mode is safe to change at runtime
and does not restart the video stream. Trackpads and pens are unaffected.
<YasSurfaceView
connectionId={connectionId}
surfaceId={surfaceId}
touchMode="pointer"
/>Every terminal has an experimental Wayland compositor available. Any command — shell, TUI, or GUI — can open Wayland surfaces:
workspace.createSession({
connectionId: "default",
rows: 24,
cols: 80,
command: "my-gui-app",
});Surfaces created by the terminal appear in the connection's surfaceStore, keyed by the terminal's PTY ID. Each surface has a surfaceId, parentId, title, appId, width, and height.
createSession({ connectionId, rows, cols, tag?, command?, cwdFromSessionId? })closeSession(sessionId)restartSession(sessionId)focusSession(sessionId | null)search(query, { connectionId? })setVisibleSessions(sessionIds)addConnection(...)/removeConnection(connectionId)/reconnectConnection(connectionId)
A connection can report an identifier of your choosing (a user, a device, your
app's own session ID) to name it in the server's client list: yas client list, and Manage → Clients in the browser. That list shows each client's
terminal and surface view sizes, and a shared terminal or window is sized to
fit the smallest, so the identifier is how to tell whose view that is. YAS
passes it on as is: it must be UTF-8 of at most 1 KiB and nothing else is
checked, and several clients may report the same one.
new YasWorkspace({
wasm,
connections: [{ id: "default", transport, clientIdentifier: "alice@laptop" }],
});On a YasConnection of your own, pass clientIdentifier in its options, and
call updateClientIdentifier(text) to replace it on the live session. The Rust
client takes HelloOptions::identifier and Client::set_identifier.
All transports share a common set of options (YasTransportOptions):
| Option | Default | Description |
|---|---|---|
reconnect |
true |
Auto-reconnect on disconnect |
reconnectDelay |
500 |
Initial reconnect delay (ms) |
maxReconnectDelay |
10000 |
Maximum reconnect delay (ms) |
reconnectBackoff |
1.5 |
Backoff multiplier |
connectTimeoutMs |
none (WS) / 10000 (WebRTC) |
Connection timeout (ms) |
// Authenticated native YAS edge.
const edge = { type: "websocket", url, passphrase, options };
// Native read-only WebRTC share through the signaling hub.
const share = { type: "share", hubUrl, passphrase };
// Low-level native byte-stream transport for an existing peer connection.
const dataChannel = createWebRtcDataChannelTransport(peerConnection);
// Its ordered channel selector is `yas.v1`.Or implement your own:
interface YasTransport {
connect(): void;
send(data: Uint8Array): void;
close(): void;
readonly status: ConnectionStatus;
readonly authRejected: boolean;
readonly lastError: string | null;
addEventListener(type: "message" | "statuschange", listener: Function): void;
removeEventListener(
type: "message" | "statuschange",
listener: Function,
): void;
}crates/client (package yas-client) is the Rust client the yas CLI is
built on. It is not on crates.io yet; depend on it by path or git:
yas-client = { path = "../yas/crates/client" }
tokio = { version = "1", features = ["full"] }Its TLS (wss://, TURN), QUIC (wt://) and uplink crypto run on ring by
default. A program built on aws-lc-rs, rustls's own default, keeps ring out of
its binary with
default-features = false, features = ["aws-lc-rs"]. The TLS clients YAS
makes use the program's process-wide rustls provider when it installed one
(CryptoProvider::install_default), else the feature's (aws-lc-rs when both
are on), so they don't panic in a program that has both.
Client::connect(target, &ConnectOptions) accepts every target the CLI does
(local[:NAME], socket:PATH, ssh:[USER@]HOST, tcp:, ws(s)://,
wt://, uplink:, share:, remote names); Client::from_stream speaks YAS
over any byte stream you already have. A Client is one session, Clone and
safe to use from many tasks at once: each process stream, file transfer and
subscription is flow-controlled on its own.
use yas_client::{Client, ConnectOptions, process::Command};
let client = Client::connect(Some("ssh:build@ci"), &ConnectOptions::named("my-app")).await?;
let output = client.spawn(Command::new("cargo").args(["test", "--workspace"]).current_dir("/src/app"))
.await?
.output()
.await?;
println!("{} {}", output.status, String::from_utf8_lossy(&output.stdout));- Processes (
process): argv/env/cwd, piped or null stdin, merged stderr, detachable, operation IDs for retries, wait/signal/kill, list/watch. Each command gets its own process group; when the command exits the server terminates the group (SIGTERM, thenSIGKILLafterYAS_PROCESS_KILL_GRACE, 2 s), so background children die with it unless theysetsid. Ordinary processes die with their session; detachable ones survive it. The module docs spell this out. A server admits 16 live processes per session and 64 in total by default;yas server --process-max-per-session N(and the other--process-max*flags) raises that, andClient::process_limits()reports what the server enforces. - Files (
fs): open a root, read (whole, limited, ranged) with BLAKE3 hashes, stat (lstatsemantics), list, write with preconditions (Any,Absent,Hash) through a staged commit, mkdir -p, rename, remove, symlink. - KV and environment (
kv): get/put/delete with preconditions, list, watch; the server environment (ENV_GET). - Terminals (
terminal): PTYs that belong to the server, not the session, with the IDsyas terminal listshows.start_terminalwith aTerminalCommand(a program, a shell command line or the default shell; cwd, env, size, tag, deadline), then type into it (write_terminal), read its screen as text, resize, signal, restart, close, and wait for it to exit. When its shell reports commands (OSC 133, docs/shell-integration.md):wait_terminal_command,terminal_commands(exit codes, command lines) andterminal_output(what one command printed);terminal_cwdwith OSC 7. - Surfaces (
surface): the windows GUI programs map on the server's compositor. List them, capture one as PNG or AVIF, click, scroll, press keys (key_combo("ctrl+c"),typed_keys("hello{enter}")) or enter text, resize, focus and close them:yas surface, for programs that drive GUIs. - Errors (
Error): connection failures, lost sessions, server statuses (is_not_found,is_conflict), timeouts, unsupported operations, protocol violations. - SSH (
ssh, re-exportedyas-ssh):SshOptions::in_memory(HostKeyPolicy::Pinned(keys))withwith_private_key(text, passphrase)authenticates with keys held in memory and trusts only pinned host keys, touching no~/.sshfile; passSshPool::with_options(options)asConnectOptions::ssh. Hosts whose sshd does not forward to sockets, or that have no POSIX shell (Windows), are reached by runningyas connect --stdiothere: automatically inSshMode::Auto, only that way inSshMode::Exec(SshOptions::remote_commandoverrides the command). A refused host key isssh::Error::HostKey, with the presented key'sfingerprint. - Hosting (
host, Unix):HostedServer::start(HostOptions::new("yas"))runs a privateyas server --fd-channelchild in a 0700 directory (own state, cache and runtime directories by default);connect()hands it a fresh socketpair per session,socket_path()exposes its private socket forYAS_SOCK, and dropping it (or the host process dying) stops the server. - Pipes:
Transport::from_split(child_stdout, child_stdin)andClient::from_transportrun a session over a child's pipes. Runyas connect --stdioat the other end (an SSH exec channel, ordocker exec -i CONTAINER yas connect --stdio) and it relays them to that side's server (docs/transports.md). - Read-only viewers:
wire::read_only::ReadOnlyIngressturns the bytes a client sends into a read-only session's (it rewrites the HELLO, then passes everything through), for a relay that forwards clients to an ordinary socket;yas server --read-only-sock PATHis a socket that does it for every session (docs/transports.md).
cargo run -p yas-client --example run -- local -- uname -a is a complete
example; crates/cli/tests/client_host.rs exercises the API end to end.
You can also run a @yas-run/core client server-side (Node/Bun/Deno) to drive a
local yas server over its unix-domain socket — e.g. to script terminals or run
headless commands. The non-browser building blocks live under the
@yas-run/core/node subpath (kept out of the package root so node:net and
runtime globals never leak into browser bundles). The packages are plain ES
modules; install @yas-run/core with its matching @yas-run/browser peer:
npm install @yas-run/core @yas-run/browserimport { YasWorkspace, exitCodeFromStatus, nullLogger } from "@yas-run/core";
import { NodeUnixSocketTransport, loadYasWasm } from "@yas-run/core/node";
// `loadYasWasm()` initializes the @yas-run/browser WASM off-browser: it reads
// the colocated yas_browser_bg.wasm from disk and feeds it to init(), so you
// never touch raw wasm bytes. (If you depend on a self-initializing
// `@yas-run/browser/node` build it is returned as-is.)
const wasm = await loadYasWasm();
const socket = process.env.YAS_SOCK;
if (!socket) {
throw new Error("YAS_SOCK must name the server's explicit Unix socket");
}
const transport = new NodeUnixSocketTransport(socket);
const workspace = new YasWorkspace({
wasm,
logger: nullLogger, // no-op logger; omit to log lifecycle events to console
connections: [{ id: "default", transport }],
});
// Families are negotiated after the HELLO round trip; wait before creating.
await new Promise<void>((resolve) => {
const check = () => {
if (!workspace.getSnapshot().ready) return;
unsubscribe();
resolve();
};
const unsubscribe = workspace.subscribe(check);
check();
});
const session = await workspace.createSession({
connectionId: "default",
rows: 24,
cols: 80,
command: "my-command",
});The unix transport carries the YAS byte stream (the preface, then 4-byte
little-endian length-prefixed frames) for you; there is no need to
re-implement the wire format. BunUnixSocketTransport and
DenoUnixSocketTransport are the runtime-native equivalents; Deno needs
--allow-read --allow-write for the socket path.
When a session's process exits, its YasSession.state becomes "exited" and
YasSession.exitStatus carries the raw status from the server:
>= 0— normal exit; the value is the exit code.< 0— terminated by a signal; the value is the negated signal number.EXIT_STATUS_UNKNOWN— not yet collected.
exitCodeFromStatus(status) maps that to a conventional shell exit code
(unknown → 1, signalled → 128 + signal), and formatExitStatus(status)
renders "exited(<code>)" / "signal(<n>)". Both mirror the yas CLI.
import { exitCodeFromStatus } from "@yas-run/core";
workspace.subscribe(() => {
for (const s of workspace.getSnapshot().sessions) {
if (s.state === "exited" && s.exitStatus !== null) {
console.log(`${s.id} exited with code`, exitCodeFromStatus(s.exitStatus));
}
}
});fd-channel lets an external process own yas server's lifecycle and control which clients connect via SCM_RIGHTS fd passing. See the transport reference and the working examples:
- Python
- Bun
- Rust:
yas_client::host(see above)
@yas-run/core/transports exports YasUplinkTransport, which connects directly
through an uplink relay using browser WebCrypto (X25519, AES-GCM, SHA-256).
Pass the producer's pinned connection URL and the client's 43-character
base64url private key separately: new YasUplinkTransport(url, privateKey).
generateUplinkKeyPair() generates an identity and uplinkPublicKey(privateKey)
derives its public key. Authorize that public key on the producer.
YasWorkspace accepts the equivalent
{ type: "uplink", url, identity: privateKey } transport configuration.
Identity storage belongs to the embedding app; the transport does not persist
keys. The app must be served from a trusted origin independently of the relay,
and the relay control endpoint must permit that origin through CORS.
YasNoiseTransport wraps custom opaque carriers and supports optional encrypted
routed datagrams. See the uplink protocol for
carrier requirements, limits, key rotation, and migration from Ed25519/TLS.