Skip to content

RFC-0003 — IPC protocol

Status: proposed v0 · 2026-10-07 · Implementation target, pending owner review.

Use a restricted JSON-RPC 2.0 profile over an AF_UNIX SOCK_STREAM socket. JSON is inspectable, maps directly to QML/JavaScript, and keeps C++, Python and Rust clients simple. Cap’n Proto and MessagePack add code generation or binary tooling before we have evidence that control traffic needs it. Pixels, PCM audio and per-frame GPU commands never travel over this protocol.

The daemon owns the public socket and brokers shell and extension requests. The plugin owns compositor truth. The shell never calls an extension directly through a privileged backdoor. M0 implements hello, ping, subscribe, snapshot and runtime exit first; other methods return unsupported until advertised. An unadvertised method MUST return -32601, even if its schema exists.

Socket: $XDG_RUNTIME_DIR/hyprune/$HYPRLAND_INSTANCE_SIGNATURE/control.sock. Validate the instance component against [A-Za-z0-9_.-]+; refuse unset/unsafe values. Never fall back to /tmp. Directory mode 0700, socket mode 0600, owned by the session UID. Bind without following symlinks; only remove a stale socket after proving ownership and no live listener. Check SO_PEERCRED on acceptance.

Each frame is a 4-byte unsigned big-endian byte length, followed by that many bytes of UTF-8 JSON. Length excludes the prefix. Accept lengths 1..1,048,576 inclusive. Handle partial reads and multiple frames per read. Reject zero/oversized lengths before allocating, invalid UTF-8, duplicate object keys, nesting over 64, NaN and infinity. Close on malformed transport; well-framed invalid JSON gets -32700 with null ID before close. JSON batches and client notifications are forbidden in this profile. Requests have a nonempty string ID (max 128 characters), unique while in flight; never reuse an ID on one connection. Responses have the same ID, exactly one of result/error, and no method. Only malformed-request errors may have null ID. Unsolicited server notifications have no ID.

First request MUST be session.hello within 5 seconds. It selects one exact protocol string, initially 0.1; no silent downgrade. A mismatch returns -32001 and closes. Subsequent hello returns -32600. Maximum 64 outstanding calls, 4 MiB queued output per peer, and 10 seconds to complete a received frame. Overflow disconnects the slow client; it reconnects and takes a fresh snapshot. Never block the compositor to drain a socket. Ping is optional liveness, every 10 seconds; clients reconnect after 30 seconds without a response with exponential backoff capped at 10 seconds and jitter.

Every request includes params, even when {}. Method parameters, results, notifications and state are defined in hyprune/schema/v0/ipc.schema.json. The schema admits only this protocol version’s known fields. Clients validate against their negotiated version, not whatever schema is newest on the internet.

Method Params Result Required grant
session.hello protocols: string[], client: {id, name}, capabilities: string[], optional token protocol, sessionId, capabilities, methods, events none
session.ping {} monotonicMs none after hello
state.subscribe topics: ["state"] subscriptionId, snapshot state.read
state.snapshot subscriptionId snapshot state.read
state.unsubscribe subscriptionId ok: true state.read
world.load worldId, optional spawnId revision world.control
surface.focus surfaceId, intent: "pointer" | "keyboard" revision surface.control
movement.set modeId revision movement.control
shell.claim outputs: string[] leaseId, revision shell.control
shell.release leaseId revision shell.control
shell.overlay leaseId, outputId, visible, exclusiveInput revision shell.control
runtime.exit {} revision runtime.control

IDs are opaque strings, scoped to the session; no memory addresses or process IDs. Installed world IDs and mode IDs are reverse-domain identifiers, never paths or shell commands. world.load resolves only installed, validated packages. Mutations serialize at the authority boundary, return only when applied (or rejected), and return the committed state revision. A successful load means the new scene is ready and atomically activated; failure leaves the old world intact. Calls have a 30-second server deadline, return -32006 if not committed by then, and MUST NOT apply after that error. After a connection loss the client must inspect state before retrying; lost responses may represent completed commands. There is no implicit retry or cancellation method in v0.

surface.focus(pointer) selects a pointer target without moving the workspace or granting keyboard focus. keyboard is an explicit user intent and may return unsupported for hidden-workspace surfaces. The plugin resolves compositor focus safely after the frame. runtime.exit leaves world mode and restores ordinary desktop input; it does not terminate Hyprland or the helper.

hello.methods is the implemented subset of the table, filtered by grants; hello.events is the supported event subset. Ping and hello are always implemented. A conforming interactive shell requires the methods listed in RFC-0008; a partial M0 core is not yet shell-conformant.

A snapshot is {sessionId, revision, state}. revision is an integer 0..9007199254740991; restart the session before overflow. state has exactly runtime, world, surfaces, movement, shell. Runtime contains active and outputs (stable opaque output IDs). World is null or {id, spawnId}. Surfaces is an array of {id, kind, title, logicalSize: [width,height], focused}. Movement is {modeId, position: [x,y,z], orientation: [x,y,z,w]} in world coordinates. Shell is {leases: [{id, outputs, overlays: [{outputId, visible, exclusiveInput}]}]}. Surface titles are privileged session information: no state.read, no snapshot.

state.subscribe atomically captures a snapshot at revision R and registers delivery of every subsequent visible state change. Queue the response before any notification for that subscription. At most one subscription per connection (duplicate -> -32004). state.snapshot resets that subscription’s baseline in the same way: discard its unsent deltas, enqueue snapshot response, then later deltas. Previously written frames precede the response and can be discarded by a client awaiting resync.

state.delta params: {subscriptionId, sessionId, baseRevision, revision, changes}. Changes is a nonempty object containing whole replacement values for one or more of the five state keys. No JSON Patch, array-index edits or implicit merges. A revision increments on each published transaction, not each monitor render; revisions may jump when coalescing. Apply only when session matches and baseRevision equals the local revision. Replace included keys atomically and advance to revision, which MUST be greater than baseRevision. On any gap stop applying deltas, call state.snapshot, and resume after its response. Subscription IDs and all session-scoped IDs die on reconnect. Snapshots are bounded by the frame limit; reject resource creation that would exceed it with -32008 rather than publish truncated state.

Publish at most 30 state deltas/second per peer; coalesce pending replacements while preserving the original baseRevision and latest revision. Desktop actions may commit faster; their replies may precede a later coalesced delta. The shell interpolates presentation between samples (about 70 ms buffer), but never predicts grants, focus, world activation or lease ownership. Runtime simulation and input do not depend on this rate.

Other notifications:

Event Params Delivery
runtime.notice level: info/warning/error, code, message hello-complete peers with state.read; transient, not replayed
session.revoked reason send if possible, then close immediately

No separate surface-created event: surface lifecycle is authoritative in state. There is no persistent event replay log in v0.

SO_PEERCRED proves the local UID, not extension identity. Same-UID processes are not isolated by Unix file permissions. An ordinary connection gets only ping/hello; read and control grants require a daemon-issued random 256-bit bearer token, encoded as exactly 64 lowercase hexadecimal characters. Trusted shell launch receives a token through an inherited FD, never argv, world data or logs. Tokens are bound to an approved installed client ID, daemon session, requested capability subset and expiry; hello consumes the launch token once and binds grants to the connection. Reconnect requires a new launch token from the supervisor. Client-supplied IDs are labels until authenticated. No persistent blanket token file.

The user’s local policy stores approvals for package digest + requested grants; new capabilities or changed package digest require a new approval. Daemon-supervised extension processes receive only their own socket connection/token. Sandboxing is required before advertising isolation: deny the host runtime directory, arbitrary Wayland access, host process inspection and network by default; expose only explicitly brokered FDs. Unsandboxed same-UID extensions are marked trusted and can escape the capability model through the host session. M0 loads no third-party executable extensions. The public socket never accepts GL pointers, raw input injection or arbitrary filesystem access.

Grant names are exactly state.read, world.control, surface.control, movement.control, shell.control, runtime.control. A shell may request all six, but the daemon returns only approved grants. Capability revocation closes the connection, releases leases, cancels uncommitted operations and sends session.revoked when possible. Read-only clients cannot claim overlays or move focus.

Standard errors: -32700 parse, -32600 invalid request/profile, -32601 unknown/unimplemented method, -32602 invalid params, -32603 internal. Hyprune errors: -32001 incompatible protocol; -32002 unauthorized; -32003 unknown resource; -32004 conflict/stale lease; -32005 unavailable (plugin disconnected); -32006 deadline exceeded before commit; -32007 unsupported operation; -32008 resource limit. Error data, when present, is {retryable: boolean}; never include secrets or pointers. Valid request IDs are echoed on errors. An internal failure must not crash the daemon or compositor.

v0 names the schema family; 0.1 is its first exact wire contract. Any field or semantic change requires a new protocol identifier and matching schema/changelog, even when additive. Negotiate only implemented versions. Package format versions and protocol versions evolve independently. No stable plugin ABI is promised.

Before M1, test split/coalesced frames, malformed lengths, unauthorized methods, disconnect during mutation, snapshot/subscription races, slow consumers, revocation, reconnect after daemon restart, and hidden-workspace focus rejection. The schema validates shapes; session sequencing, grants and state transitions require runtime conformance tests.

0.2 is an exact new minor contract, defined by hyprune/schema/v0.2/ipc.schema.json. Core supports both 0.1 and 0.2, preferring 0.2 when both are offered. A 0.1 peer receives only its original five state keys, methods and events. Schema library callers select validate(kind, value, method, "0.2"); the default remains 0.1. The SDK’s 0.1-mock.1 is not a real protocol and is never negotiated by core.

Rationale: the first SDK mock and shell work exposed missing camera control, destinations and routes, timed travel, UI opening, and integration data. These are world authority, not shell-local guesses. Keep the mock’s camera.set, waypoint.set, ui.open, data.publish and ui.opened/data.updated names, but replace the mock-only permission assumptions and replay model with the following contract.

All mutation results contain the committed revision; camera.get instead returns {position, orientation}. Vectors are world metres, camera/point positions are eye positions, quaternions are normalized [x,y,z,w]. Zone destinations resolve to the horizontal bounds centre, minimum Y + 1.7 metres. A Target is exactly {zoneId} or {position:[x,y,z]}. No world/package paths are accepted.

Method Params Grant
camera.get {} state.read
camera.set {position, orientation} movement.control
waypoint.set {id, label, target:Target}; also {id,label,position} for mock migration world.control
waypoint.clear {} world.control
route.set {points:Target[]} (1–64 points) world.control
route.clear {} world.control
travel.start {to:Target, durationMs?:0..10000} (default 600) movement.control
travel.cancel {} movement.control
autodrive.start {speed?:0.1..10} metres/sec (default 3.5) movement.control
autodrive.stop {} movement.control
ui.open {leaseId, outputId, panel} shell.control
ui.close {leaseId, outputId} shell.control
data.publish {sourceId, sequence, values, ttlMs?} data.publish
data.remove {sourceId} data.publish

camera.set is an explicit placement, not simulated movement; core M1 rejects roll and pitch outside ±1.49 radians with -32007. It cancels automatic motion. travel.start atomically starts a transition and returns immediately, rather than holding an RPC open for the animation. It requires active world mode, rejects concurrent automatic movement and obstructed destinations, interpolates the camera through world geometry as an intentional warp, and commits the destination on completion. Zero duration gives reduced-motion clients instant placement and an arrived event. Cancellation leaves the camera where it is. World replacement, exit, lock, and helper failure cancel motion. autodrive.start follows the explicit route from its beginning using capsule collision checks; obstruction stops it. M1 does not perform pathfinding: authors/clients supply navigable intermediate points. Routes and waypoints are world-scoped and clear on world replacement.

Core owns hold-to-open timing: M1 consumes Tab in exploration mode, opens overview after a continuous 350 ms hold, and leaves the panel open on release. Short taps do nothing. Escape closes the overlays and cancels movement; F12 remains emergency exit. UI commands require a connection-owned lease and a member output. Open/close update lease visibility and emit the corresponding event. No opaque panel identifier executes code. M1 leases/nonexclusive overlays are implemented, but exclusiveInput:true returns -32007 until the trusted launcher supplies a verified Wayland-client association; this partial implementation must not claim full RFC-0008 interactive-shell conformance.

The original five state keys remain. 0.2 adds two whole-replacement keys:

  • location: {zones, waypoint, route, autodrive, transition}. Zones are current world manifest zones. Waypoint is null or {id,label,target,position}; route contains {target,position} entries; autodrive is boolean. Transition is null or {id,outputId,phase,progress,to,monotonicMs}. The session-scoped ID remains stable throughout one operation; outputId is null before a world monitor is selected. phase is loading, departing, travelling, arrived, failed, or cancelled. Progress is null for indeterminate loading and otherwise 0..1. to is a Target for point/zone travel, or {worldId,spawnId?} for world loading. Core timestamps every transition sample. The latest terminal transition remains in state until another transition/world replacement, so a reconnect can dismiss its warp overlay correctly.
  • integrations: {sources:[{sourceId,sequence,values,monotonicMs,ttlMs}]}. Each entry is the last complete publication, removed on TTL expiry, explicit remove, or publisher disconnect.

Additional transient notifications are location.transition {id,outputId,phase,progress,to,monotonicMs}, ui.opened {leaseId,outputId,panel}, ui.closed {leaseId,outputId}, and data.updated {sourceId,sequence,values,monotonicMs,ttlMs}. Each also carries sessionId and revision linking it to committed state. Only subscribed state.read peers on 0.2 receive these; subscribe/snapshot responses precede future notifications. Events are presentation hints, not patches and not a replay log. A shell’s warp overlay uses transition phase/progress/to; a fresh snapshot is authoritative after any disconnect/gap. world.metadata from the mock is replaced by revisioned location.zones and location.waypoint, avoiding a second unversioned state channel. No separate camera state is needed: movement already carries the pose.

data.publish is a new explicit launch-token capability. state.read never grants publishing (the mock-only mapping is retired). Source IDs must equal the authenticated client ID or be beneath its dot namespace, and one connection owns each source. Each publication completely replaces values: at most 32 scalar number/boolean/string properties, string values at most 256 characters, total values at most 16 KiB. No executable values, paths to read, host metrics collection, or network privileges are implied. Sequence is an increasing safe integer per live source. Server receipt time supplies monotonicMs; TTL is 100..60000 ms, default 5000. Maximum 32 live sources and 64 KiB aggregate serialized values, at most 10 publications/sec per source; overflow returns -32008. Ownership/stale sequence conflicts return -32004; namespace violations return -32002. Publishers should remove their source when stopping; disconnect cleanup is mandatory.

For a trusted supervised SDK process, HYPRUNE_SOCKET, HYPRUNE_CLIENT_ID, and HYPRUNE_PROTOCOL describe the connection; HYPRUNE_TOKEN_FD contains exactly 64 lowercase hex bytes plus newline and is consumed/closed once. These client bootstrap names are distinct from core’s private HYPRUNE_LAUNCH_FD JSON grant-injection pipe. Reconnect requires a newly issued token; never persist one or auto-replay mutations. Core M1’s nested launcher exercises the private grant pipe; a production process supervisor and Quickshell-native transport remain separate integration work.

The shell gap notes arrived during M1. The warp sequence now includes operation ID, output, monotonic sample time, indeterminate loading and terminal failed; core flushes loading notifications while the load RPC is pending. World activation and arrival remain atomic. A disconnect detected while loading cancels the pending activation; a response lost after commit still requires snapshot reconciliation. Only one world/automatic-motion operation is active on M1’s one world monitor. F12/lock take priority; Escape cancels a pending load. Already committed timed travel may continue across requesting-client disconnect; resubscribe to recover its state.

ui.opened/ui.closed are the canonical event names, retaining SDK mock compatibility; ui.open/ui.close name the methods. Lease/output and state revision arbitrate stale events; no separate UI timer or hold progress is required. Release keeps the panel open; dismissal calls ui.close, and lease disappearance hides it. There is no acknowledgement handshake or automatic UI timeout in M1.

The remaining shell proposals are explicitly deferred: output descriptors/Wayland-name mapping (the trusted launcher must still map the selected opaque output), per-output projection/marker data, immutable world/map asset delivery and installed catalog, workspace-to-location mappings and workspace.activate, settings/approval UI, and supervisor token renewal/Wayland association. Core M1’s runtime.outputs describes the selected world monitor (retained while inactive), not all compositor outputs. These omissions must stay unavailable in the shell; they are not inferred from the new camera pose method or arbitrary package paths.