How OpenWork Headless Web Mode Works Without Electron
OpenWork's headless web mode delivers a fully functional desktop-like experience using only a Vite web server and the OpenWork server process, eliminating the need for Electron binaries through process isolation and token-based authentication.
The different-ai/openwork repository provides a lightweight, browser-based alternative to native desktop applications. By orchestrating a Vite development server alongside the OpenWork server process, this architecture provides complete API access and UI rendering without bundling Chromium or Node.js within a single Electron executable.
Startup Sequence
The headless web mode launch is orchestrated by scripts/dev-headless-web.ts, which manages an eight-step initialization process defined between lines 18 and 284.
Environment Preparation
The launcher creates a tmp/ directory to store runtime logs and the manifest file. This isolated workspace prevents conflicts with other development instances and persists data across restarts.
Instance Detection and Reuse
Before spawning new processes, the script checks for an existing manifest at tmp/dev-headless-web.json (lines 71-86). If health endpoints respond successfully, the script reuses the running instance unless the --replace flag is provided, which triggers termination of existing PIDs.
Dynamic Port Resolution
Free ports are automatically selected for the Vite UI (defaulting to 5178) and the OpenWork server (defaulting to 8778). This resolution occurs in lines 107-137 of dev-headless-web.ts, ensuring no hardcoded conflicts with other local services.
Configuration Merging
The isolated server configuration stored in tmp/headless-server.json is merged with the current workspace settings. This persistence layer ensures that UI-created workspaces survive process restarts, implemented in scripts/dev-headless-web-lib.ts between lines 78-115.
Process Spawning
Two detached child processes are launched:
- Vite UI: Executed via
pnpm --filter @openwork/app exec vitewith customVITE_*environment variables (lines 210-226) - OpenWork Server: Started via Bun executing
apps/server/src/cli.tswith merged configuration and CORS origins (lines 234-246)
The detachment prevents terminal session termination from killing the stack.
Runtime Manifest Generation
The script writes a JSON manifest to tmp/dev-headless-web.json containing the web URL, API URL, authentication tokens (token and hostToken), process IDs, workspace path, and optional Den proxy settings (lines 162-208 in dev-headless-web-lib.ts).
Graceful Shutdown Handling
Signal handlers for SIGINT and SIGTERM trigger orderly termination of all child processes via the cleanup routine in lines 257-284, while preserving the manifest for subsequent reuse.
Core Architectural Components
Launcher Script
scripts/dev-headless-web.ts serves as the entry point and orchestrator. It manages the --replace flag for force-restarting stacks, supports detached execution with --detach, and handles the lifecycle of both Vite and server processes through Node.js child process APIs.
Helper Library
scripts/dev-headless-web-lib.ts provides utilities for cryptographically secure token generation, CORS origin construction, and configuration merging. These functions replace Electron's context bridge and secure storage with standard HTTP-based authentication patterns.
Vite Development Server
Rather than embedding a browser runtime, the architecture serves the React UI (@openwork/app) through a standard Vite dev server. Clients access the application through regular browser tabs at http://127.0.0.1:5178 (by default), eliminating the need for a wrapped Chromium window.
OpenWork Server
apps/server/src/cli.ts functions as the local API layer, handling workspace management, sessions, and plugin execution. It accepts the merged configuration from tmp/headless-server.json and exposes endpoints on the resolved server port (default 8778).
Runtime Manifest
tmp/dev-headless-web.json acts as a service registry for downstream tooling. The manifest structure includes:
webUrl: Vite UI endpointopenworkUrl: API server endpointtoken: Owner-level authenticationhostToken: Administrative authenticationpids: Process identifiers for cleanup operationsdenProxy: Cloud control plane configuration
How It Avoids Electron
The architecture eliminates Electron through four specific design decisions:
Process Isolation Over Bundling
Instead of packaging the UI and server within a single Electron main/renderer process structure, OpenWork spawns them as independent child processes running in separate process groups. This isolation prevents terminal session kills from cascading while maintaining clear operational boundaries suitable for headless servers.
Standard Web Technologies
The UI runs as a plain web bundle served by Vite. Users interact through standard browser tabs rather than a wrapped native window, removing the dependency on Electron's renderer process architecture and associated memory overhead.
Same-Origin Proxy Strategy
When integrating with Den (the cloud control plane), the Vite dev server proxies /api/den requests to the remote target. This maintains same-origin security policies without requiring Electron's IPC bridge for cross-origin requests, as implemented in the proxy configuration logic.
Token-Based Authentication
Authentication relies on token and hostToken values stored in the runtime manifest and transmitted via HTTP headers. This replaces Electron's safeStorage API with standard bearer token patterns compatible with curl, automated testing frameworks, and CI/CD pipelines.
Running Headless Web Mode
Starting a New Instance
Launch the complete stack with process detachment:
pnpm dev:headless-web --detach
The launcher outputs the accessible URLs:
[dev:headless-web] Web URL: http://127.0.0.1:5178
[dev:headless-web] OpenWork server: http://127.0.0.1:8778
Reusing Existing Instances
To connect to a running stack without spawning duplicate processes:
pnpm dev:headless-web
If tmp/dev-headless-web.json references healthy endpoints, the script prints the existing URLs and exits immediately.
Force Restarting
Kill the existing stack and start fresh:
pnpm dev:headless-web --replace
This sends SIGTERM followed by SIGKILL to the PIDs recorded in the manifest before initialization.
Programmatic Manifest Access
Read the runtime configuration from tmp/dev-headless-web.json:
import { readFile } from "node:fs/promises";
async function loadManifest() {
const data = await readFile("tmp/dev-headless-web.json", "utf8");
const manifest = JSON.parse(data);
console.log("Web UI:", manifest.webUrl);
console.log("API:", manifest.openworkUrl);
console.log("Auth Token:", manifest.token);
}
loadManifest();
Custom Configuration
Run with a specific workspace or disable Den proxying:
OPENWORK_WORKSPACE=/path/to/my/workspace \
OPENWORK_DEV_DEN_PROXY=0 \
pnpm dev:headless-web
Environment variables override defaults before port resolution and config merging occur in the launcher script.
Summary
- OpenWork's headless web mode replaces Electron with a Vite server and Bun-based API process, reducing resource overhead and native dependencies while maintaining full functionality.
- The launcher script (
scripts/dev-headless-web.ts) manages an eight-step initialization including port resolution, config merging, and manifest generation for durable state management. - Process isolation through detached child processes and POSIX signal handling ensures stable long-running instances without desktop environment lock-in.
- Token-based authentication stored in
tmp/dev-headless-web.jsonenables secure API access via standard HTTP headers, eliminating the need for Electron-specific secure storage mechanisms. - The architecture supports instance reuse through health checks and manifest persistence, optimizing development workflow startup times while preventing port conflicts.
Frequently Asked Questions
How does OpenWork handle authentication without Electron's secure storage?
OpenWork generates cryptographically secure tokens (token for ownership, hostToken for administration) in scripts/dev-headless-web-lib.ts. These tokens are stored in the runtime manifest at tmp/dev-headless-web.json and transmitted via HTTP headers between the UI and server. This approach relies on filesystem permissions for security rather than OS-specific keychains, making it compatible with containerized and CI/CD environments where Electron's safeStorage API would be unavailable.
Can I run multiple headless web instances simultaneously?
Yes, provided you specify distinct workspace directories. The launcher resolves free ports automatically (defaulting to 5178 for Vite and 8778 for the API), but you should ensure unique OPENWORK_WORKSPACE values to prevent database conflicts. Each instance writes to its own isolated manifest and log files within the tmp/ directory, allowing parallel development on different projects.
What happens if the launcher process crashes or is killed?
The Vite and OpenWork server processes run in detached process groups spawned with detached flags in dev-headless-web.ts (lines 210-246). If the launcher exits unexpectedly, the child processes continue running. You can reconnect to these persistent instances by running pnpm dev:headless-web without the --replace flag, which detects the running processes via the manifest file and re-establishes the connection without restarting services.
How do I access the OpenWork API from external tools when running headless mode?
The runtime manifest at tmp/dev-headless-web.json contains the openworkUrl endpoint and authentication tokens required for API access. External scripts can parse this file to obtain the base URL and required Authorization headers. The server enables CORS origins dynamically based on the Vite URL (constructed in dev-headless-web-lib.ts), allowing browser-based tools and command-line utilities running on localhost to interact with the API seamlessly.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →