Headless agent runner built from ZCode. Runs fully headless for VMs and orchestrators, with the full interactive TUI when you need it.
It packages the zcode CLI for machines with no display: an orchestrator sends a
prompt over SSH or stdio, the agent plans, calls tools, and returns the result.
Headless runs never require a display server. For interactive sessions, zh tui opens the full zcode terminal interface.
The agent loop is the zcode agent. It reads and writes files, runs shell
commands, loads local skills, and can drive dynamic workflows. Prompt runs
default to permission mode yolo, which fits unattended VMs; scope it down
with --mode build|edit|plan when the task warrants it.
One line, no git required:
curl -fsSL https://raw.githubusercontent.com/coderbuzz/zh/main/install-remote.sh | shThe installer downloads the latest release, verifies its SHA256 checksums,
symlinks zh into ~/.local/bin, and smoke tests it. The default method is
auto: bundle when the machine already has a usable runtime (bun, or
node >= 22, the runtime contract of the prebuilt bundle), standalone binary
otherwise. The bundle reuses the runtime already installed instead of
shipping one, which is why developer machines usually get it. Pick a method
explicitly if you prefer:
# auto: bundle when bun or node >= 22 is present, binary otherwise (default)
curl -fsSL https://raw.githubusercontent.com/coderbuzz/zh/main/install-remote.sh | sh -s -- --method=auto
# standalone executable per OS/arch, no bun or node needed
curl -fsSL https://raw.githubusercontent.com/coderbuzz/zh/main/install-remote.sh | sh -s -- --method=binary
# minified bundle + launcher; runs with bun, falls back to node
curl -fsSL https://raw.githubusercontent.com/coderbuzz/zh/main/install-remote.sh | sh -s -- --method=bundle
# build from source on the target machine; needs bun
curl -fsSL https://raw.githubusercontent.com/coderbuzz/zh/main/install-remote.sh | sh -s -- --method=sourceWhat auto does, in order:
- Looks for a usable runtime in
PATHfirst, then in known install locations (~/.bun/bin,/usr/local/bin,/opt/homebrew/bin, and~/.nvm/versions/node/*):bun(any 1.x) ornode>= 22. - Runtime found: it installs the bundle. Runtimes whose directory is not in
PATHare recorded in~/.local/share/zh/.zh-runtime, and the launcher prefers those pinned binaries, sozhalso works in non-interactive shells. - No runtime: it installs the standalone binary for this OS/arch. If the
release has no binary asset for the platform, the install fails with
instructions to install bun or node (or force
--method=bundle).
Files live under ~/.local/share/zh (--home to change) and the
launcher goes to ~/.local/bin/zh (--prefix to change). Re-running the
installer repairs an existing install (an install root left at the old
~/.local/share/zheadless is moved to the new path automatically, with a
symlink left behind); remove everything with:
curl -fsSL https://raw.githubusercontent.com/coderbuzz/zh/main/install-remote.sh | sh -s -- --uninstallFrom a clone of this repository, sh install.sh does the same as
--method=source plus a bun install refresh. Verify any install with
zh version.
Releases are built by GitHub Actions on every v* tag: a platform
independent bundle, standalone binaries for linux-x64, linux-arm64,
darwin-x64, and darwin-arm64, the playwright-core driver, and the TUI runtime
asset. The darwin binaries are unsigned; macOS may
ask you to clear the quarantine flag before first run.
zh login zai --no-browser prints an OAuth URL; complete it on any machine
with a browser. zh login bigmodel is the alternative provider. Credentials
are shared with the regular zcode CLI storage, so a machine that already
logged in through zcode needs no extra setup.
Run one prompt in the current directory and exit:
zh -p "audit the failing tests and fix them"Machine-readable output for an orchestrator:
zh -p "write a migration for the users table" --output-format jsonSet a session goal instead of a one-shot prompt:
zh --target "keep the flaky test suite green"Other flags worth knowing:
--mode build|edit|plan|yolosets the permission mode (default for-pisyolo)--attach <path>adds a local file to the prompt, repeatable--resume <sessionId>/-ccontinue a previous session--disallowed-tools "Bash Edit"removes tools for one run--cwd <path>runs in another directory
zh tui opens the upstream zcode terminal interface: the zcode logo screen,
prompt composer, model and effort switching, slash commands, and the session
workflow views. It needs a real terminal; without a TTY it prints
TUI requires an interactive terminal. and exits.
The TUI runtime (@zcode/tui and its dependency closure) ships as its own
release asset, zheadless-tui-runtime-<tag>.tar.gz, which the installer
extracts into <install root>/node_modules next to the playwright-core
driver. On the binary install, zh tui re-executes the bundled
dist/zcode.cjs through the binary's own bun runtime (BUN_BE_BUN=1): a
compiled binary cannot resolve the TUI's on-disk dependencies by itself, and
the bundle on disk resolves them normally.
For long-lived orchestration, app-server and agent-server run the ZCode
Protocol server: a process that speaks newline-delimited JSON frames on stdio
and stays alive across many prompts, so the orchestrator keeps one connection
instead of spawning zh per request.
zh app-serverBoth commands start the same protocol server; the two names match the host roles upstream zcode uses:
app-serverfronts the whole application surface: the session index, workspace config, multiple conversations, attachments, and usage stats. Use it when your orchestrator is a service that manages many sessions or workspaces, for example a pool of agents behind a dashboard. This is how the zcode desktop, web, and mobile clients drive the CLI.agent-serveris the same server used as a single-agent endpoint: one process, one workspace, one conversation stream. Use it when a job or an editor extension owns exactly one agent and wants a persistent connection, for example a CI bot that keeps a session warm across steps.
The wire contract lives in packages/shared/src/zcode-protocol-v4/ (method
table in transport.ts) and the dispatcher in
packages/bootstrap/src/zcode-protocol/server.ts. Methods include
v4/connection/flow, v4/controller/subscribe, v4/conversation/subscribe,
v4/conversation/rowsRange, the v4/conversation/workflowRun* family,
v4/attachment/*, v4/usage/stats, and v4/command.
An orchestrator skeleton looks like this (exact frame params come from the schema files above):
import { spawn } from "node:child_process";
import { createInterface } from "node:readline";
const server = spawn("zh", ["app-server"], { stdio: ["pipe", "pipe", "pipe"] });
const frames = createInterface(server.stdout);
frames.on("line", (line) => console.log("frame:", JSON.parse(line)));
const send = (frame) => server.stdin.write(`${JSON.stringify(frame)}\n`);
send({ jsonrpc: "2.0", id: 1, method: "v4/connection/flow", params: {} });Killing the process (or closing its stdin) shuts the session down cleanly;
the server reports Protocol input closed on stderr and exits.
zh web serves the web UI over HTTP and spawns the same agent child, so a
browser on another machine can run prompts against a workspace:
zh web --host 0.0.0.0 --port 4180The banner prints the local and network URLs with a per-start token. The
static shell is public by design; /api/* and /ws are token-gated from a
non-loopback client (401 without the token, 200 and 101 with the token from
the banner). Bound to loopback, the default, the server starts with no
token and gates nothing.
zh web opens a browser automatically when bound to loopback. On a headless
host (no DISPLAY/WAYLAND_DISPLAY, or running under systemd or SSH) it
skips the attempt and says so; --open forces the attempt and --no-open
always disables it. A missing xdg-open is reported as a hint instead of
crashing the server.
The browser keeps its WebSocket to /ws alive with server pings and an
app-level heartbeat, and reconnects automatically with backoff after an
idle-proxy timeout, a network switch, or a server restart; the active
session is restored on reconnect without a page refresh. If the WebSocket
upgrade is rejected by an auth wall (for example an expired Cloudflare
Access session, which answers with a redirect instead of 101), reconnecting
stops and the page reloads so the browser can sign in again.
The server runtime needs Node >= 22 on PATH (the node-pty native addon);
ZH_WEB_NODE overrides the lookup. The web dist ships as its own release
asset, zheadless-web-<tag>.tar.gz, which install-remote.sh --web
extracts into the install root; zh web finds <root>/web and
<root>/server by walking up from its own entry.
Upstream web-mode stubs, unchanged: the file picker, the remote workspace
wizard (SSH, WSL, Docker), the embedded browser, and phone remote render
but do not drive a real backend. Branding covers the shell surfaces only
(tab title, favicon, boot logo, and the document title strings in the web
entry); strings inside @zcode/ui stay upstream.
Free plan offers: when the signed-in account has a claimable free-token
plan (the desktop app's limited-time promotions), every page load checks
for it — GET /api/coding-plan/manual-claim/previews mirrors the zcode-plan
billing preview API — and the shell renders a Claim banner. Claiming opens
a dialog and goes through POST /api/coding-plan/manual-claim/claim; the
Aliyun captcha verify param is obtained in the browser (config from
/api/coding-plan/captcha-config), while the server adds the auth,
captcha, app-version, and platform headers. With no usable captcha config
the claim is refused client-side, matching the desktop app.
Since 0.5.17 the headless web server runs the automation scheduler itself
(upstream puts it in the desktop app's dedicated scheduler process, which
zh web does not have). Every automation created from a chat session
(CronCreate) or the UI is stored in ~/.zcode/v2/tasks-index.sqlite and
polled every 20 seconds by the server process:
- A due automation is dispatched in-process: the scheduler claims it
(single-flight, crash-safe), creates or resumes the target task, sends the
saved prompt, and records the run in the automation's History tab
(
dispatched/failed_to_dispatchwith retry backoff, then the real turn outcomesucceeded/failed/stopped). - A fire missed while the host was not running is not silently rolled to the
next occurrence. Within a 5-minute grace window it still fires
(catch-up); beyond it the run is recorded as
skipped(missed_while_host_not_running) and a pure one-shot automation is finalized ascompleted, so the History tab always shows what happened. - Run now works: the server wires the immediate dispatcher that desktop hosts provide, so the UI button enqueues a manual run instead of failing with "Automation immediate dispatcher is unavailable". Manual runs do not move the cron schedule.
zh web reads the same ~/.zcode/v2 state as the desktop app. The
credential store encrypts its values with a key derived from the local
platform, home directory, and user name, so a credentials.json copied
from another machine cannot be decrypted there. On decrypt failure the
OAuth session entries are removed (upstream forced-logout semantic) and
the UI falls back to the welcome screen. Since 0.5.1 this is loud: the
server logs credential value failed to decrypt, backs the original file
up next to itself as credentials.json.corrupt-<hash>.bak, and logs
cleared local OAuth session when the OAuth entries are removed.
Two supported ways to bring state over:
- Set
ZCODE_CREDENTIAL_SECRETon the web server host to the source machine's secret. With the default derivation the secret iszcode-credential-fallback:<platform>:<homedir>:<username>, for examplezcode-credential-fallback:darwin:/Users/indra:indra. If the source machine already setsZCODE_CREDENTIAL_SECRET, use that same value. Restore the.bakbackup if the store was already rewritten. - Or start clean: copy only
provider_config.json(API-key providers and model rules) and sign in through the web welcome screen.
The minimal state set for zh web is credentials.json plus
provider_config.json. tasks-index.sqlite and its -shm/-wal
sidecars are not needed; do not copy them out of a running desktop app.
zh skills list shows the local skills the agent will load, from
~/.agents/skills and the workspace. Dropping a skill into one of those
directories is enough; the next run picks it up.
Bun is the priority runtime. The zh launcher (a plain sh script) checks
for bun first, adds ~/.bun/bin to PATH for fresh installs, and runs the
freshest entry it can find: the TypeScript source in a source checkout, or
the prebuilt dist/zcode.cjs in a bundle install. Only when bun is absent
does the launcher fall back to node with the bundle. The --method=binary
install skips the launcher entirely: zh is a standalone executable with
the bun runtime embedded.
When the installer picks the bundle and the validated runtime lives outside
the default PATH (version managers, ~/.bun before the profile reload), it
records the absolute path in ~/.local/share/zh/.zh-runtime; the
launcher sources that file and prefers the pinned binaries over PATH
lookup, so zh also works in non-interactive shells.
bun run build # or: sh build-all.shThis compiles every workspace package with tsc in dependency order
(13 packages). dynamic-workflow additionally runs a codegen step
(scripts/generate-libs.mjs) on first build; the build script runs it
automatically when the generated file is missing. packages/formal-proof
is a Vite demo app outside the main build.
bun run build inside packages/cli additionally produces the node bundle
at packages/cli/dist/zcode.cjs: plain bun run build keeps a debug source
map, bun run build -- --release minifies and drops the map (about 12 MB),
which is what release assets contain. The bundle is built by Bun's own
bundler; ZCODE_BUILD_VERSION=<tag> overrides the version reported by
zh version, which the release pipeline sets from the git tag.
packages/cli: entry point and command routing (prompt, protocol servers)packages/core: runtime, output, environmentpackages/bootstrap: agent bootstrapping (model factory, skills, auth, telemetry)packages/provider,packages/provider-node: AI SDK providerspackages/adapters: node adapters (logger, clipboard, search)packages/dynamic-workflow,packages/dynamic-workflow-runtime: workflow enginepackages/contracts,packages/shared,packages/shared-types: types and shared codepackages/i18n,packages/telemetry,packages/model-option-map: support modulespackages/zcode-cua: computer-use broker contractsbin/zh: launcher script;install.sh: source installer;install-remote.sh: one-line remote installer (auto, binary, bundle, or source)packages/tui: the interactive terminal interface (zh tui), bundled todist/index.jsand kept external from the CLI bundle;scripts/stage-tui-runtime.mjs: builds the TUI runtime release asset.github/workflows/release.yml: release pipeline (bundle, standalone binaries, and checksums published on everyv*tag)config/provider/zcode-builtin.json: bundled provider/model catalog the CLI seeds from on first runpatches/: pinned@ai-sdkpatches applied on install
Production ready for unattended headless work.
- One-line install on Linux (x64, arm64) and macOS (Apple silicon, Intel), with no bun, node, or git required. Every install method is self-contained: the browser driver and the interactive TUI runtime come with the install.
zh -preturns plain text or machine-readable JSON with session and usage data for orchestrators.zh app-server/zh agent-serverhold one NDJSON connection across many prompts, for orchestrators that keep sessions warm.zh tuiopens the full zcode terminal interface. It needs a real terminal (a local terminal or an SSH session with a TTY) and exits with a clear message otherwise. Runningzhwith no arguments opens it.- Browser use drives a real headless Chromium through
--browser-use=headless.
Known limits, stated plainly: no Windows binaries yet; darwin binaries are unsigned, so macOS may ask you to clear the quarantine flag on first run; the protocol server's frame-level handshake is documented as a schema reference rather than a worked example.
If you find a gap, the fastest path is zh -p against this repository.
MIT. This repository builds on code extracted from the ZCode project.