Skip to content

Wire Protocol

Hive uses two WebSocket-based protocols:

  1. Client protocol - between hive client and hived daemon (port 9178)
  2. Peer protocol - between hived cluster nodes (port 9179)

All messages are JSON text frames using serde tagged enums ("type" field, snake_case) - except terminal output, which the daemon sends as a raw binary WebSocket frame (see Binary output frame). Terminal bytes are escape-dense, so JSON-encoding them dominated CPU on the session's busy owner node under load; the binary frame ships the payload verbatim. Every client decodes it back into the same logical Output message.

Client Protocol

Endpoint: ws://<host>:<port>/ws

Connection Lifecycle

Client                           Server
  |                                |
  |---- ClientMessage::Auth ----->|
  |                                |
  |<--- ServerMessage::AuthResult-|  (success: true/false)
  |                                |
  |---- ClientMessage::* -------->|  (commands)
  |<--- ServerMessage::* --------|  (responses/output)
  |        ...                     |
  |---- WS Close ---------------->|

The first message must be Auth. Any other message before auth results in an error and disconnect.

ClientMessage (client -> daemon)

json
// Authenticate
{"type": "auth", "token": "abc123"}

// Create a new session
// Optional run_as_user runs the session as that OS user (takes precedence over
// any run-as user derived from project_id); optional node_id targets a peer.
// provider selects the SDK agent CLI (default "claude"); hidden keeps the
// session out of the terminals listing (internal agent chats). tags assigns
// free-form tags at creation - a session tagged "dev-server" is launched hidden
// as a Run-panel dev server whose bound port is detected from output.
{"type": "new_session", "working_dir": "/path", "mode": "sdk", "model": "opus", "name": "my-session", "extra_args": ["--verbose"], "run_as_user": "alice", "node_id": null, "provider": "claude", "hidden": false, "tags": []}

// Attach to session output stream
{"type": "attach", "session_id": "550e8400-e29b-41d4-a716-446655440000"}

// Detach from current session
{"type": "detach"}

// Send prompt to a session
{"type": "send_input", "session_id": "550e8400-...", "input": "explain this code"}

// List all sessions
{"type": "list_sessions"}

// Kill a session
{"type": "kill_session", "session_id": "550e8400-..."}

// Get cluster status
{"type": "cluster_status"}

// Adopt a running Claude process
{"type": "adopt_session", "pid": 12345, "name": "my-session", "working_dir": "/path/to/project"}

// Rename a session
{"type": "rename_session", "session_id": "550e8400-...", "name": "new-name"}

// Rename a cluster node
{"type": "rename_node", "node_id": "a1b2c3d4-...", "name": "production-1"}

// List directory contents
{"type": "list_directory", "path": "/home/user"}

// Browse a project's working directory (M1 file explorer).
// path is interpreted relative to project.working_dir; null/empty = root.
// Path traversal (".." or absolute) is rejected with project_file_error.
{"type": "list_project_files", "project_id": "550e8400-...", "path": "src"}

// Read a UTF-8 text file inside a project's working directory.
// Returns project_file_content on success, project_file_error on missing /
// binary / oversized (>1 MB) / non-UTF-8 / path-traversal failures.
{"type": "read_project_file", "project_id": "550e8400-...", "path": "README.md"}

// Read one window of a binary file inside a project's working directory for
// streaming download (file explorer "Download"). Stateless: each request seeks
// to offset and returns up to len bytes (daemon caps len to its 8 MiB per-frame
// bound). No total-size cap - the client loops windows until eof, so a file of
// any size streams without buffering a whole copy in memory. Answered with
// project_file_chunk (data base64, plus offset / total_size / eof) or
// project_file_chunk_error, correlated by request_id.
{"type": "read_project_file_chunk", "request_id": "d1", "project_id": "550e8400-...",
 "path": "build/artifact.bin", "offset": 0, "len": 8388608}

// Working-tree git status (M2 git changes panel).
// Daemon shells out to `git status --porcelain=v2 --branch -z` inside
// project.working_dir and parses the result. "Not a git repo" is NOT an
// error - git_status_result returns is_repo=false. git_error is reserved
// for transport / spawn failures (git missing, project unknown, peer down).
{"type": "git_status", "project_id": "550e8400-..."}

// Unified diff for a single project file.
// staged=false returns `git diff`; staged=true returns `git diff --cached`.
{"type": "git_diff", "project_id": "550e8400-...", "path": "src/lib.rs", "staged": false}

// Write a UTF-8 text file inside a project's working directory (M3 editor).
// base_sha256: the digest the editor last observed (from project_file_content).
// Daemon refuses with project_file_write_conflict when on-disk digest differs.
// Pass null to force-write (creating a new file or overwriting after conflict).
// 1 MiB cap; atomic temp+rename; preserves existing file mode on Unix.
{"type": "write_project_file", "project_id": "550e8400-...",
 "path": "src/lib.rs", "content": "fn main() {}\n", "base_sha256": "abc..."}

// Project filesystem mutations (file explorer: create / rename / move / copy /
// delete). Each carries a client-generated request_id echoed back on
// project_fs_action_result / project_fs_action_error so the client can match
// the reply. All paths are project-relative; traversal is rejected. create /
// move / copy refuse to overwrite an existing destination (no clobber).

// Create a new empty file (exclusive; fails if the path already exists).
{"type": "create_project_file", "project_id": "550e8400-...", "path": "src/new.rs", "request_id": "r1"}

// Create a new directory (parent must already exist).
{"type": "create_project_directory", "project_id": "550e8400-...", "path": "src/sub", "request_id": "r2"}

// Move or rename an entry (one message serves both). from must exist; to must not.
{"type": "move_project_entry", "project_id": "550e8400-...", "from": "a.txt", "to": "b.txt", "request_id": "r3"}

// Copy an entry (directories copy recursively). from must exist; to must not.
{"type": "copy_project_entry", "project_id": "550e8400-...", "from": "src", "to": "src-copy", "request_id": "r4"}

// Delete an entry (directories delete recursively). The project root can't be deleted.
{"type": "delete_project_entry", "project_id": "550e8400-...", "path": "old.txt", "request_id": "r5"}

// Upload a host file (exclusive; data is base64-encoded bytes). The new file is
// chowned to the project's run-as user. Capped at 25 MiB.
{"type": "upload_project_file", "project_id": "550e8400-...", "path": "assets/logo.png", "data": "<base64>", "request_id": "r6"}

// Subscribe to filesystem changes under a project's working directory. While
// subscribed the daemon pushes a debounced project_files_changed whenever
// anything under the root changes on disk (terminals, git, builds, peers).
// The watch is per-connection; re-subscribing replaces the prior watch and it
// is dropped on unwatch_project_files or disconnect. In cluster mode the watch
// is routed to the project owner and change events stream back over the peer
// transport, so remote-owned projects auto-refresh too.
{"type": "watch_project_files", "project_id": "550e8400-..."}

// Cancel a watch_project_files subscription (no-op if not watching).
{"type": "unwatch_project_files", "project_id": "550e8400-..."}

// Application-level keepalive. Sent periodically by clients that cannot
// observe WS protocol pongs (the browser shim - browsers auto-pong
// invisibly); the daemon replies keepalive_ack so the client has guaranteed
// inbound traffic to drive its connection-staleness indicator. Liveness is
// observational on both sides: a widening gap only flips a UI indicator and
// a daemon log line, never a teardown - connection death is detected by the
// kernel (TCP keepalive / TCP_USER_TIMEOUT), not by application timers.
{"type": "keepalive"}

// Deprecated cluster-management messages (leaderless clustering has no
// election, leader, or manual sync - the daemon accepts these for wire
// compatibility but they are no-ops). Every connected node already serves
// writes locally and gossips them, and reconnect runs anti-entropy.
{"type": "force_election"}
{"type": "promote_node", "node_id": "a1b2c3d4-..."}
{"type": "trigger_sync"}

// Get cluster configuration
{"type": "get_cluster_config"}

// Update cluster configuration at runtime
{"type": "update_cluster_config", "peers": ["10.0.0.2:9179"], "heartbeat_interval_ms": 1000}

// -- Durable chats --

// List first-class Claude, Codex, OpenCode, and OpenRouter chats.
{"type": "list_chats"}

// Create a dedicated-workspace chat. workspace_dir is required for dedicated.
{"type": "create_chat", "title": "Research", "provider": "claude",
 "model": null, "workspace_kind": "dedicated", "workspace_dir": "/home/user/research"}

// Create a Notes chat. The receiving node resolves its configured vault path,
// so workspace_dir is omitted.
{"type": "create_chat", "title": "Notes research", "provider": "codex",
 "model": null, "workspace_kind": "notes", "workspace_dir": null}

{"type": "open_chat", "chat_id": "550e8400-..."}
{"type": "rename_chat", "chat_id": "550e8400-...", "title": "New title"}
{"type": "delete_chat", "chat_id": "550e8400-..."}
{"type": "stop_chat", "chat_id": "550e8400-..."}

// linked_notes contains validated vault-relative paths and is accepted only
// when the chat has workspace_kind "notes".
{"type": "send_chat_message", "chat_id": "550e8400-...",
 "text": "Update the summary", "linked_notes": ["projects/hive.md"]}

// ── Remote Terminals ───────────────────────────────────────────────────────

// Open a new PTY terminal running an arbitrary shell or command
// command: the shell/binary to launch (e.g. "bash", "powershell.exe", "zsh")
// args: optional extra arguments; name: optional display name
{"type": "open_terminal", "working_dir": "/home/user", "command": "bash", "args": null, "name": "my-shell"}

// Request an owner-authoritative PTY resize. request_id is echoed by
// pty_resize_result after the session owner handles the request, including when
// the live PTY is on another cluster node.
{"type": "resize_pty", "session_id": "550e8400-...", "request_id": "uuid-r1", "rows": 36, "cols": 120, "connection_id": "uuid-connection"}

// ── Agent Teams ────────────────────────────────────────────────────────────

// Create a new team (daemon creates a leader session and returns the Team).
// node_id routes creation to that cluster node, as in new_session; omitted or
// null means the receiving node.
{"type": "create_team", "name": "research-team", "working_dir": "/path", "model": null, "extra_args": null, "project_id": null, "node_id": null}

// Spawn a worker session under an existing team
{"type": "spawn_worker", "team_id": "550e8400-...", "working_dir": "/path", "model": null, "extra_args": null, "name": "worker-1"}

// Relay a message between team members
// to_session_id omitted: sends to leader (from worker) or broadcasts to all workers (from leader)
// The receiving daemon forwards this request through the cluster node that
// owns a target team member, which performs persistence and agent delivery.
{"type": "relay_team_message", "team_id": "550e8400-...", "from_session_id": "uuid-a", "to_session_id": "uuid-b", "payload": "{\"task\":\"analyse\"}"}

// List all teams
{"type": "list_teams"}

// Get a specific team by ID
{"type": "get_team", "team_id": "550e8400-..."}

// Fetch the daemon-persisted operator-visible conversation for a team. In a
// cluster, the connected node routes this read to a member-owning daemon.
{"type": "get_team_messages", "team_id": "550e8400-..."}

// Delete a team and kill all its sessions
{"type": "delete_team", "team_id": "550e8400-..."}

// ── Team autopilot ─────────────────────────────────────────────────────────

// Spawn a worker and block until it reports (or the timeout fires).
// On timeout, the daemon returns wait_id so the caller can resume via team_wait.
{"type": "handoff", "team_id": "550e8400-...", "prompt": "audit auth.rs", "worker_name": "explorer", "timeout_ms": 60000}

// Spawn a worker asynchronously; daemon replies with worker_assigned.
{"type": "assign", "team_id": "550e8400-...", "prompt": "fan-out task"}

// Bounded block on pending workers. Pass wait_id (from a prior timeout) to resume.
{"type": "team_wait", "team_id": "550e8400-...", "for_session_ids": null, "timeout_ms": 60000, "wait_id": null}

// At-least-once inbox replay for relayed messages. since_seq=null returns everything.
{"type": "get_team_inbox", "team_id": "550e8400-...", "session_id": "uuid-worker", "since_seq": 7}

ServerMessage (daemon -> client)

json
// Auth result
{"type": "auth_result", "success": true, "message": "authenticated"}

// Reply to a client keepalive (see ClientMessage::keepalive) - gives clients
// without WS-pong visibility guaranteed inbound traffic for their staleness
// indicator.
{"type": "keepalive_ack"}

// Session created
{"type": "session_created", "session": {"id": "550e8400-...", "name": null, "mode": "sdk", "status": "idle", "working_dir": "/path", "model": "opus", "created_at": "2025-01-01T00:00:00Z", "last_active": "2025-01-01T00:00:00Z", "attached_clients": 0}}

// Durable chat metadata includes workspace_kind: "dedicated" | "notes".
{"type": "chat_list", "chats": [{
  "id": "550e8400-...", "title": "Notes research", "provider": "codex",
  "model": null, "workspace_kind": "notes", "workspace_dir": "/home/user/.hive/notes",
  "session_id": null, "created_at": "2025-01-01T00:00:00Z",
  "updated_at": "2025-01-01T00:00:00Z"
}]}

// Creation and metadata/session changes send the same ChatInfo shape.
{"type": "chat_created", "chat": {"id": "550e8400-...", "...": "..."}}
{"type": "chat_updated", "chat": {"id": "550e8400-...", "...": "..."}}
{"type": "chat_deleted", "chat_id": "550e8400-..."}

// append=false replaces the client transcript; append=true adds new raw
// provider JSONL lines in order. Linked-note metadata is stored in the user
// transcript entry so clients can render the paths as openable chips.
{"type": "chat_history", "chat": {"id": "550e8400-...", "...": "..."},
 "lines": ["{\"type\":\"hive_user\",\"text\":\"Update the summary\",\"linked_notes\":[\"projects/hive.md\"],\"at\":1735689600000}"],
 "append": false}

// Session list
{"type": "session_list", "sessions": [{"id": "...", ...}]}

// Attached to session
{"type": "attached", "session_id": "550e8400-..."}

// Detached from session
{"type": "detached", "session_id": "550e8400-..."}

// Streamed output. `seq` is the per-session monotonic chunk counter assigned by
// the session owner; clients dedupe against `SessionHistory.last_seq` (drop any
// Output with seq <= last_seq). NOTE: on the wire this is NOT the JSON shown
// below - it is a binary frame (see "Binary output frame"). The JSON form is the
// logical message every client reconstructs after decoding.
{"type": "output", "session_id": "550e8400-...", "seq": 42, "data": "..."}

// Ordered PTY geometry boundary. All Output frames before seq were generated
// at the prior geometry; clients resize their terminal grid here before
// parsing subsequent output. request_id is present for a client-requested
// boundary and absent for an internal or compensating boundary.
{"type": "pty_resize_boundary", "session_id": "550e8400-...", "seq": 43, "cols": 120, "rows": 36, "request_id": "uuid-r1"}

// Correlated owner-generated result for resize_pty.
// status: applied | unchanged | not_owner | not_found | failed
{"type": "pty_resize_result", "session_id": "550e8400-...", "request_id": "uuid-r1", "status": "applied", "cols": 120, "rows": 36, "message": null}

// Session status changed
{"type": "session_status_changed", "session": {"id": "...", "status": "running", ...}}

// Session killed
{"type": "session_killed", "session_id": "550e8400-..."}

// Session unknown - the addressed node has no live process for this session and
// cannot resolve an owner for it. In a cluster this is the owner's authoritative
// disclaim: a node that forwarded a request to the recorded owner treats this as
// proof the session is dead, prunes its stale local routing, and reports
// session_killed to its client. A client receiving it directly treats the
// session as ended.
{"type": "session_unknown", "session_id": "550e8400-..."}

// Cluster status. `role`, `term`, `leader_id`, and `leader_address` are
// vestigial under leaderless clustering: `role` is always "leader", `term` is
// always 0, and `leader_id`/`leader_address` always point at the connected node
// itself. They are retained in the payload for wire compatibility; clients
// treat every node as a co-equal authority and do not act on them.
{"type": "cluster_status", "cluster": {"node_id": "a1b2c3d4-...", "role": "leader", "term": 0, "leader_id": "a1b2c3d4-...", "leader_address": "10.0.0.1:9178", "peers": [...]}}

// Cluster config response
{"type": "cluster_config", "node_id": "a1b2c3d4-...", "peer_port": 9179, "peers": ["10.0.0.2:9179"], "heartbeat_interval_ms": 1000, "election_timeout_min_ms": 3000, "election_timeout_max_ms": 5000, "min_quorum": null, "advertise_addresses": ["10.0.0.1:9178"]}

// Cluster action acknowledgement
{"type": "cluster_action_ack", "action": "rename_node", "ok": true, "message": "Node renamed"}

// Non-fatal operational notice for the current client
{"type": "client_notice", "notice": {
  "level": "warning",
  "code": "output_lagged",
  "message": "This client is falling behind on live session output.",
  "detail": "Hive skipped 12 buffered output chunks while catching up.",
  "session_id": "550e8400-e29b-41d4-a716-446655440000"
}}

// Directory listing response
{"type": "directory_listing", "path": "/home/user", "parent": "/home", "entries": ["projects/", "documents/", "file.txt"]}

// Project directory listing (response to list_project_files).
// Paths are project-relative; entries are sorted dirs-first then alphabetical.
// kind is "dir" | "file" | "symlink"; size is bytes (0 for directories).
{"type": "project_file_list", "project_id": "550e8400-...", "path": "src",
 "entries": [
   {"name": "components", "path": "src/components", "kind": "dir", "size": 0},
   {"name": "lib.rs", "path": "src/lib.rs", "kind": "file", "size": 4096}
 ]}

// Project file content (response to read_project_file).
// sha256 is lowercase hex of the raw bytes (used by M3 for write conflicts).
{"type": "project_file_content", "project_id": "550e8400-...", "path": "README.md",
 "content": "# Hi", "sha256": "abc..."}

// Project file error (list_project_files or read_project_file failed).
{"type": "project_file_error", "project_id": "550e8400-...", "path": "../etc/passwd",
 "message": "path escapes project root"}

// One window of a streaming download (response to read_project_file_chunk).
// data is base64-encoded; offset echoes the window start; total_size is the
// file's full size; eof is true once this window reaches the end. A failed
// window answers with project_file_chunk_error { request_id, message }.
{"type": "project_file_chunk", "request_id": "d1", "data": "...",
 "offset": 0, "total_size": 134217728, "eof": false}

// Git status result (response to git_status).
// is_repo=false means the working dir is not a git repository - empty change
// lists, no branch, not an error. status letters: M A D R C T U.
{"type": "git_status_result", "project_id": "550e8400-...",
 "is_repo": true, "branch": "main", "upstream": "origin/main",
 "ahead": 1, "behind": 0,
 "staged":   [{"path": "src/lib.rs", "status": "M"}],
 "unstaged": [{"path": "README.md",  "status": "M"}],
 "untracked": ["new.txt"]}

// Git diff result (response to git_diff). patch is raw unified-diff text.
{"type": "git_diff_result", "project_id": "550e8400-...",
 "path": "src/lib.rs", "staged": false,
 "patch": "diff --git a/src/lib.rs b/src/lib.rs\n@@ -1 +1 @@\n-old\n+new\n"}

// Git error (git_status / git_diff failed for a transport / spawn reason).
{"type": "git_error", "project_id": "550e8400-...", "message": "git not on PATH"}

// Project file saved (response to write_project_file). sha256 is the new base.
{"type": "project_file_saved", "project_id": "550e8400-...",
 "path": "src/lib.rs", "sha256": "def456..."}

// Write conflict: on-disk sha256 differs from base_sha256 (or file is gone).
// The client typically prompts the user to reload, overwrite, or merge.
{"type": "project_file_write_conflict", "project_id": "550e8400-...",
 "path": "src/lib.rs", "current_sha256": "ff00ff00..."}

// Project filesystem mutation succeeded (create / move / copy / delete).
// request_id echoes the originating request; path is the affected
// project-relative path (the new location for move/copy, the created path for
// create, the removed path for delete).
{"type": "project_fs_action_result", "project_id": "550e8400-...",
 "request_id": "r3", "path": "b.txt"}

// Project filesystem mutation failed (path escapes root, destination exists,
// source missing, IO error, peer unreachable). request_id echoes the request.
{"type": "project_fs_action_error", "project_id": "550e8400-...",
 "request_id": "r3", "message": "a file or directory with that name already exists"}

// Files changed under a watched project's working directory (pushed to clients
// with an active watch_project_files subscription). Debounced and coarse - no
// path detail; the client re-lists the directories it cares about (root plus
// expanded subfolders) in response.
{"type": "project_files_changed", "project_id": "550e8400-..."}

// Error
{"type": "error", "message": "session not found"}

// ── Remote Terminals ───────────────────────────────────────────────────────

// A new shell terminal was opened (response to open_terminal)
// The session uses mode "pty" and kind {"type": "shell", "command": "bash"}
{"type": "terminal_opened", "session": {"id": "550e8400-...", "mode": "pty", "kind": {"type": "shell", "command": "bash"}, "status": "running", ...}}

// ── Agent Teams ────────────────────────────────────────────────────────────

// A new team was created (response to create_team)
{"type": "team_created", "team": {"id": "...", "name": "research-team", "leader_session_id": "...", "worker_session_ids": [], "status": "active", "created_at": "...", "last_active": "...", "project_id": null}, "leader_session": {"id": "...", ...}}

// A worker was spawned and added to the team (response to spawn_worker)
{"type": "worker_spawned", "team": {"id": "...", "worker_session_ids": ["worker-uuid"], ...}, "worker_session": {"id": "worker-uuid", ...}}

// A newly persisted operator-visible message. The same stable message.id is
// returned by team_message_history so clients can merge live and history data.
{"type": "team_message", "team_id": "550e8400-...", "message": {"id": "uuid-message", "from_session_id": "uuid-a", "to_session_id": "uuid-b", "payload": "{\"result\":\"done\"}", "sent_at": "2026-07-25T12:00:00Z"}}

// Per-recipient result for relay_team_message. delivered only includes members
// whose live session accepted the input. Unavailable sessions and agent-process
// input failures appear in undelivered for the app to surface.
{"type": "team_message_delivery", "team_id": "550e8400-...", "message_id": "uuid-message", "delivered": ["uuid-b"], "undelivered": []}

// Response to list_teams or get_team
{"type": "team_list", "teams": [{"id": "...", "name": "research-team", "status": "active", ...}]}

// Response to get_team_messages. The daemon returns the durable conversation in
// display order, allowing desktop and mobile clients to load the same chat.
{"type": "team_message_history", "team_id": "550e8400-...", "messages": [{"id": "uuid-message", "from_session_id": "uuid-a", "to_session_id": "uuid-b", "payload": "{\"result\":\"done\"}", "sent_at": "2026-07-25T12:00:00Z"}]}

// A team was updated
{"type": "team_updated", "team": {"id": "...", ...}}

// A team was deleted
{"type": "team_deleted", "team_id": "550e8400-..."}

// ── Team autopilot ─────────────────────────────────────────────────────────

// Response to handoff. completed=true means result is populated. completed=false
// with wait_id set means the handoff timed out and may be resumed via team_wait.
{"type": "handoff_result", "team_id": "550e8400-...", "worker_session_id": "uuid-w", "success": true, "result": "done", "completed": true, "wait_id": null}

// Response to assign. Worker is running; collect its result via team_wait + the
// usual worker_result_received / team_updated flow.
{"type": "worker_assigned", "team_id": "550e8400-...", "worker_session_id": "uuid-w"}

// Response to team_wait. completed_workers reported within the window;
// pending_workers are still working. timed_out + wait_id signal resumption.
{"type": "team_wait_result", "team_id": "550e8400-...", "completed_workers": ["uuid-w"], "pending_workers": [], "timed_out": false, "wait_id": null}

// Response to get_team_inbox. messages are seq-ordered; next_seq is the cursor
// the caller stores and re-presents as since_seq on the next call.
{"type": "team_inbox", "team_id": "550e8400-...", "session_id": "uuid-w", "messages": [{"seq": 1, "from_session_id": "uuid-leader", "payload": "...", "delivered_at": "..."}], "next_seq": 1}

TeamMessage and TeamMessageHistory describe the operator-visible Messages panel. Each TeamChatMessage.id is generated before persistence and live delivery, so clients merge history and concurrent live events by ID rather than relying on arrival order. In a cluster, GetTeamMessages uses a team member as an ownership anchor and is forwarded to that member's daemon, which holds the durable conversation. This lets a client connected through any node load the same history. GetTeamInbox is separate recipient-specific at-least-once delivery state used by agents for crash recovery.

RelayTeamMessage is owner-authoritative in a cluster. The connected daemon selects a team member as the delivery anchor and forwards the entire request to that member's owning node. That node persists the message, writes the outbox, injects the session input, and returns the stable TeamMessage plus TeamMessageDelivery. It does not acknowledge a recipient until the live session accepts the input. An unavailable member or failed agent process is returned in undelivered for the app to surface. Agent-process input failures also include a ServerMessage::Error with the underlying reason.

client_notice is deliberately non-fatal. It exists for "something important is happening, but your request still worked" cases, and clients should surface it to the user without treating it like an error. Current notice codes are:

  • cluster_owner_lookup - Hive is still converging on the session owner, so the attach is being routed to the owning node
  • output_lagged - the client fell behind on output and Hive skipped buffered chunks while catching up
  • output_backpressure - the client could not keep up for an extended period, so Hive dropped that output stream to protect the rest of the connection

Binary output frame

Terminal output (ServerMessage::Output) is the only message sent as a binary WebSocket frame instead of JSON. Terminal bytes are dense with control/ESC sequences; JSON-escaping every one of them (ESC becomes ``) is a per-byte scan plus a large size inflation, on the highest-volume traffic and on the session's busy owner node - so it dominated CPU under load. The binary frame ships the payload verbatim.

Layout:

byte 0       : tag = 0x01 (Output v1)
bytes 1..17  : session_id (16-byte UUID)
bytes 17..25 : seq (u64, big-endian)
bytes 25..   : raw UTF-8 terminal payload

The daemon's reader splits PTY output on UTF-8 boundaries, so each frame's payload is valid UTF-8. Every client (CLI, the app's native layer, and the browser/PWA shim) decodes the frame back into the logical Output message; nothing else in the protocol changes. Only terminal output uses this path - all control/lifecycle messages remain JSON text. Per the no-backward-compatibility policy the whole fleet upgrades together, so there is no JSON-output fallback.

Cluster note: a session owner still JSON-encodes output when forwarding to other nodes (PeerMessage::SessionOutput); converting the peer path to the same binary frame is a planned fast-follow.

Write Handling in a Cluster

Leaderless clustering has no leader and no redirect. Every connected node serves writes locally: it applies the mutation to its own replicated state, gossips it to all peers (PeerMessage::SessionUpserted / SessionRemoved / ProjectUpserted / TeamUpserted, etc.), and answers the client directly.

A non-owning node forwards requests that must reach a specific session's live process - interactive input, resize, and output subscription - to the recorded owner via PeerMessage::ForwardedRequest / SubscribeOutput (see Request Forwarding below). It also routes GetTeamMessages through a member owner because operator-visible Team chat is durable on that daemon rather than replicated cluster state. State mutations are never forwarded; the receiving node is its own authority.

PTY resize uses this correlated request/response route rather than a separate fire-and-forget peer message. The owner inserts PtyResizeBoundary into the ordered session output stream before output from the new geometry, so clients on every node apply the same rows and columns at the same stream position.

SessionInfo Object

json
{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "name": "my-session",
  "mode": "sdk",
  "status": "idle",
  "working_dir": "/home/user/project",
  "model": "opus",
  "created_at": "2025-01-15T10:30:00Z",
  "last_active": "2025-01-15T10:35:00Z",
  "attached_clients": 1,
  "adopted_pid": null,
  "project_id": null,
  "node_id": null,
  "kind": {"type": "claude"},
  "provider": "claude",
  "hidden": false,
  "team_id": null,
  "is_team_leader": false,
  "agent_working": false
}

mode: "sdk" | "pty" | "adopted"

  • "pty" - interactive terminal session; output is raw terminal data.
  • "sdk" - headless agent driven turn-by-turn. Each prompt (sent via send_input) spawns the provider CLI in stream-json mode; Output frames carry newline-delimited JSON the client parses into a chat transcript. The daemon threads the provider's own conversation id (--resume) across turns so context is preserved.
  • "adopted" - an external Claude process registered with the daemon.

provider: which runtime backend backs an SDK-mode session - "claude", "codex", "opencode", or "openrouter". OpenRouter uses Hive's native HTTP backend. Ignored for PTY/adopted sessions.

hidden: when true, the session is kept out of the terminals/sessions listings. Used for durable Chats and other headless agent runtimes that have their own UI. Absent/false for normal sessions.

node_id: UUID of the cluster node that owns the session, stamped by the daemon when the session list/created message is built for an app client. null when running standalone (no cluster). Lets the app label a session's node even when it has no explicit project.

status: "running" | "idle" | "detached" | "exited"

kind: describes what process the session runs.

  • {"type": "claude"} - a Claude Code CLI session (SDK or PTY mode)
  • {"type": "shell", "command": "bash", "args": null} - an arbitrary shell or command process

team_id: UUID of the team this session belongs to, or null.

is_team_leader: true if this session is the leader/coordinator of its team.

agent_working: true while the owning daemon detects an agent working spinner in the terminal title. The daemon publishes changes with the session metadata so terminal sidebar spinners stay consistent across clients and cluster nodes, including clients that are not attached to the terminal output stream.

Team Object

json
{
  "id": "660f9500-f30c-52e5-b827-557766551111",
  "name": "research-team",
  "leader_session_id": "550e8400-...",
  "worker_session_ids": ["worker-uuid-1", "worker-uuid-2"],
  "status": "active",
  "created_at": "2025-01-15T10:30:00Z",
  "last_active": "2025-01-15T10:35:00Z",
  "project_id": null
}

status: "active" | "inactive"

Note: teams work in both standalone and cluster modes. In standalone mode the daemon keeps teams in memory; in cluster mode they are replicated to all nodes via PeerMessage::TeamUpserted / TeamRemoved and persisted in SQLite on every node.

Peer Protocol

Endpoint: ws://<host>:<peer_port>/peer (incoming) or direct TCP (outgoing).

Used for inter-node cluster communication. All messages are PeerMessage JSON.

Peer Connection Lifecycle

Connecting Node                    Accepting Node
  |                                  |
  |---- PeerHello ------------------>|
  |                                  |  (validate cluster_token)
  |<--- PeerHelloAck ---------------|
  |                                  |
  |---- StateSnapshot ------------->|  (anti-entropy: both sides push their
  |<--- StateSnapshot --------------|   full state on connect, merged per-object)
  |        ...                       |

Handshake

json
// Connecting node identifies itself
{"type": "peer_hello", "node_id": "a1b2c3d4-...", "cluster_token": "secret", "term": 0, "display_name": "node-a"}

// Accepting node responds
{"type": "peer_hello_ack", "node_id": "b2c3d4e5-...", "term": 0, "success": true, "message": null, "display_name": "node-b"}

// Rejected (bad token)
{"type": "peer_hello_ack", "node_id": "b2c3d4e5-...", "term": 0, "success": false, "message": "invalid cluster token"}

Legacy consensus messages

The heartbeat / heartbeat_ack / request_vote / vote_response / leader_announce / start_election_now / clear_stickiness_for_election / promote_request / request_catchup variants are retained in the PeerMessage enum for wire compatibility but are ignored by handle_peer_message under leaderless clustering - there is no election, no heartbeat-driven leadership, and no leader-push catch-up. They carry a term field that is always 0.

State Replication

There is no leader push. Each node applies a local write to its own state and gossips it once to all peers (a full mesh means one hop reaches everyone); recipients apply it locally and never re-broadcast. Sessions carry a generation and projects/teams carry an updated_at so a recipient resolves conflicts last-writer-wins and rejects stale or reordered upserts. Removed sessions leave a time-bounded tombstone so a late upsert cannot resurrect them (a higher generation from the live owner can punch through - this self-heals an erroneous removal during cluster churn). The term and seq fields on these messages are vestigial (always 0) and ignored.

json
// Full snapshot - sent by both sides on connect (anti-entropy). The receiver
// merges it per-object (session/project/team union with tombstone + LWW
// rules); per-node `version` counters are NOT comparable, so a snapshot is
// never rejected wholesale.
{"type": "state_snapshot", "term": 0, "state": {"sessions": {...}, "projects": {...}, "version": 42}}

// Incremental: session created/updated (gossiped on local mutation)
{"type": "session_update", "term": 0, "session": {"info": {...}, "output_history": [...]}}

// Incremental: session removed (records a tombstone on every node)
{"type": "session_removed", "term": 0, "session_id": "550e8400-..."}

// Incremental: project created/updated
{"type": "project_upserted", "term": 0, "project": {"id": "550e8400-...", "updated_at": "...", ...}}

// Incremental: project deleted
{"type": "project_deleted", "term": 0, "project_id": "550e8400-..."}

Node Management

json
// Request a peer to rename itself
{"type": "peer_rename_node", "name": "new-name"}

// Broadcast own display name change to all peers
{"type": "peer_display_name_update", "name": "new-name"}

Request Forwarding

State writes are never forwarded - every node applies them locally. Forwarding exists only to reach a session's live process on its owning node (interactive input, resize, output subscription). The originating node sends a forwarded_request to the recorded owner; the owner runs it and replies with forwarded_response. A hops budget bounds re-forwarding so a misrouted or phantom-owner request cannot bounce around the mesh forever.

json
// Originating node -> session owner
{"type": "forwarded_request", "request_id": "uuid-...", "payload": "{\"type\":\"send_input\",...}", "hops": 4}

// Owner -> originating node
{"type": "forwarded_response", "request_id": "uuid-...", "payload": "{\"type\":\"...\",...}"}

Project File Watching

Mirrors the session-output subscription model. A node that has a client watching a remote-owned project asks the owner to watch the working directory; the owner streams coarse, debounced change ticks back keyed by subscriber_id.

json
// Subscriber -> Owner: start watching a project's working directory
{"type": "subscribe_project_files", "subscriber_id": "uuid-...", "project_id": "550e8400-..."}

// Subscriber -> Owner: stop watching
{"type": "unsubscribe_project_files", "subscriber_id": "uuid-..."}

// Owner -> Subscriber: files changed (Lossy - idempotent, debounced)
{"type": "project_files_changed", "subscriber_id": "uuid-..."}

Health Endpoint

A simple HTTP health check is available:

GET http://<host>:<port>/health
Response: "ok" (200)

Hive - remote AI coding agents over WebSocket.