How Herdr Headless Server Mode Works: Architecture and Event Loop Implementation

Herdr’s headless server mode runs the full application state machine without a local terminal, streaming rendered UI frames to thin clients over a Unix-domain socket while processing API requests and maintaining PTY runtimes.

Herdr is a terminal workspace manager that implements a detached server architecture for persistent sessions. The herdr headless server mode—accessed via the herdr server command—eliminates direct terminal dependencies by replacing the standard TUI event loop with an asynchronous Tokio runtime. This allows the server to continue managing workspaces after client disconnections, handle JSON API requests, and support live handoff updates without dropping PTY state.

Architecture Overview

The headless implementation centers on the HeadlessServer struct defined in src/server/headless.rs. Key components include:

  • run_server() – The entry point in src/main.rs (lines 46–48) that initializes logging, raises file-descriptor limits, loads configuration, and spawns the async runtime before delegating to the headless implementation.
  • HeadlessServer – A struct holding the App state, a Unix-domain listener on herdr-client.sock, client connection maps, foreground-client tracking, and shutdown flags (lines 104–122).
  • Event Loop – The HeadlessServer::run method (lines 173–229) that replaces App::run() when no TUI is present, driving state updates, input routing, and frame streaming.
  • Protocol Layer – Binary client protocol definitions in src/protocol/mod.rs handling ServerMessage enums, frame encoding, and size limits.
  • Client Management – Connection acceptance logic in src/server/client_accept.rs and state structures in src/server/clients.rs.

Startup Sequence (herdr server)

When you execute herdr server, the following initialization occurs:

// src/main.rs → server::headless::run_server()
pub fn run_server() {
    init_logging();
    raise_file_limit();
    let config = load_config();
    start_json_api_socket(&config);  // herdr.sock
    let rt = tokio::runtime::Runtime::new().unwrap();
    let app = App::new(config);
    // Disable local sound/terminal notifications
    let server = HeadlessServer::new(app);
    server.run();
}

The server binds a client socket at herdr-client.sock (path resolution handled in socket_paths.rs) with strict permissions. If the socket is already bound, run_server() aborts immediately with a clear error (lines 43–49).

The Headless Event Loop

The HeadlessServer::run method implements a non-blocking event loop that never touches stdin or the host terminal. Each iteration performs these steps:

  1. Shutdown Check – Exits if self.shutting_down or a SIGINT was captured.
  2. Event Draining – Calls drain_internal_events_with_forwarding() to route clipboard writes, sound notifications, and state changes to the foreground client.
  3. API Processing – Handles JSON commands from herdr.sock via handle_api_request_with_shutdown_check().
  4. Client Acceptance – Polls the non-blocking UnixListener for new connections (250 ms deadline) via accept_client_connections.
  5. Server Events – Processes messages from client threads (input, scroll events) through drain_server_events.
  6. Scheduled Tasks – Executes resize polls, animation timers, and autosave logic via handle_scheduled_tasks_headless.
  7. Conditional Render – If needs_render is set and app.can_render_now() returns true, generates a virtual frame.

The loop uses tokio::select! to coordinate these operations, ensuring the server remains responsive while maintaining minimal CPU usage during idle periods.

Client Connections and Input Routing

When a thin client connects to herdr-client.sock, the server creates a ClientConnection entry with a unique client_id. The implementation tracks a foreground driver—the client that dictates shared runtime size, keybindings, and theme settings.

Input handling follows this path:

  • Raw keyboard and mouse events arrive as binary protocol messages.
  • The server translates these into RawInputEvent instances.
  • Events are fed to App::route_client_events for processing by the active workspace.
  • Special events (clipboard paste, scroll buffers) route through dedicated handlers like handle_terminal_attach_scroll.

The foreground client receives all forwarded notifications (toast, sound alerts) via ServerMessage::Notify variants.

Rendering and Frame Streaming

Unlike the TUI mode that writes directly to a terminal, the headless server renders to a virtual buffer:

// src/server/headless.rs → render_and_stream()
let frame = self.app.render_to_buffer(effective_area);
let data = FrameData::new(frame, encoding_config);
self.broadcast(ServerMessage::Render(data));

The render_and_stream function (lines 379–398) generates a ratatui::buffer::Buffer containing the complete UI state. This buffer is encoded into FrameData using ANSI escape sequences (with optional Kitty graphics protocol support) and broadcast to all attached clients via ServerMessage::Render.

Clients decode these frames and display them locally, achieving a terminal-agnostic UI that functions over SSH or containerized environments.

Graceful Shutdown and Live Handoff

The server supports two termination modes:

Standard Shutdown

  • Triggered by Ctrl+C or the herdr server stop command.
  • Sets should_quit flag → calls initiate_shutdown() → complete_shutdown().
  • Pauses all PTY readers, persists session state via app.save_session_now(), removes Unix sockets, and exits cleanly.

Live Handoff (herdr update --handoff)

  • Temporarily pauses PTY readers without killing processes.
  • Exports runtime state and file descriptors via perform_live_handoff (lines 670–764).
  • Spawns a replacement server process that imports PTY handles through run_handoff_import_server.
  • Transfers ownership of herdr.sock and herdr-client.sock atomically.

This mechanism enables zero-downtime binary updates while preserving all active terminal sessions.

Practical Usage Examples

Starting a Headless Server

herdr server

Expected output:


herdr server running; you can use any herdr CLI command in another terminal.
api socket: /home/user/.local/share/herdr/herdr.sock
client socket: /home/user/.local/share/herdr/herdr-client.sock
logs: /home/user/.local/share/herdr/herdr-server.log

Attaching a Client

From another terminal or machine:

herdr client

The client connects to herdr-client.sock, receives the initial full frame, and enters an interactive mode where keystrokes are forwarded to the server.

API-Only Interaction

Create a workspace without attaching a UI:

curl --unix-socket $(herdr status --format=api-socket) \
     -X POST \
     -d '{"type":"CreateWorkspace","name":"production"}' \
     http://api/

The JSON API socket (herdr.sock) operates independently of client connections, allowing automation scripts to manage workspaces while users remain attached via thin clients.

Live Binary Update

Update Herdr without disconnecting PTY sessions:


# Terminal 1: Existing server

herdr server stop

# Terminal 2: Immediate handoff

herdr update --handoff

The handoff protocol exports all pane state from src/server/handoff.rs and imports it into the new process version.

Summary

  • Herdr headless server mode runs the complete App logic in a Tokio async runtime without terminal dependencies, located in src/server/headless.rs.
  • The HeadlessServer::run event loop processes internal events, API requests, and client connections concurrently, never blocking on terminal I/O.
  • Virtual rendering via render_and_stream encodes ratatui buffers as FrameData and streams them to thin clients over herdr-client.sock.
  • Input routing maps client keystrokes from the binary protocol to RawInputEvent instances processed by the standard application logic.
  • Live handoff in perform_live_handoff enables zero-downtime updates by transferring PTY file descriptors and socket bindings to replacement processes.

Frequently Asked Questions

How does Herdr handle client reconnections in headless mode?

When a client disconnects, the server retains the workspace state in the App struct. Subsequent connections create new ClientConnection entries that receive the current frame buffer immediately. Since the server maintains the canonical PTY state—not the client—reconnections resume the session exactly where it was left, provided the server process remains running.

What is the difference between the API socket and the client socket?

The API socket (herdr.sock) exposes a JSON-over-HTTP interface defined in src/api.rs for programmatic control (creating workspaces, sending commands). The client socket (herdr-client.sock) uses a binary protocol defined in src/protocol/mod.rs for thin clients that need interactive terminal emulation, receiving rendered frames and sending keyboard input.

Can the headless server run without Tokio?

No. The implementation relies on tokio::select! and Tokio’s async runtime to coordinate the event loop, handle non-blocking Unix socket accepts, and manage background tasks like autosave and animation timers. The run_server() function explicitly constructs a Tokio runtime before instantiating the HeadlessServer.

How does the 250ms polling interval affect performance?

The 250 millisecond deadline in tokio::select! balances latency and CPU efficiency. It ensures the server checks for new client connections frequently enough for responsive attach/detach operations while yielding CPU during idle periods. The render loop only executes when needs_render is set by PTY updates, preventing unnecessary frame generation.

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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →