DeviceRail wire protocol
devicerail-protocol defines the cross-language DTOs and JSON-RPC wire contract shared by the daemon and language clients. Rust traits remain internal implementation details; this crate's serialized forms are the compatibility boundary.
The 174 generated Protocol 1.5 Draft 2020-12 schemas are checked in under
protocol/schema/v1,
and 89 cross-language examples are enumerated by
fixtures/manifest.json.
All 24 public methods have a typed request and success-response baseline.
Regenerate schemas with cargo run -p devicerail-schema-gen -- write; use
--check to detect missing, changed, or stale files.
The type-only
@devicerail/protocol
package is
generated from those checked-in schemas. Its strict TypeScript contract loads
all Golden Fixtures with satisfies, so language clients do not maintain a
second handwritten DTO set.
Rust conformance suites can enable the non-default fixtures Cargo feature to
access the exact embedded manifest and fixture catalog without depending on a
repository-relative filesystem path. Normal protocol consumers should leave
that feature disabled.
Envelope and framing
DeviceRail uses a request/response subset of JSON-RPC 2.0:
- every request and response contains
"jsonrpc": "2.0"; - every request has a string or non-negative JavaScript-safe integer
id; - after negotiating
request.control.v1, the five device methods andmedia.stream.capturemay settimeoutMsto a positive JavaScript-safe millisecond value; params, when present, must be an object or array; known no-parameter methods reject non-empty values;- every response has exactly one of
resultorerror; - batch requests are not supported; stdio remains response-only, while a negotiated event WebSocket can emit only the two typed stream notifications;
- protocol DTO fields use
camelCase.
The stdio transport uses NDJSON framing: one complete JSON-RPC message per line, with a 1 MiB JSON payload limit in both directions and bounded input/output queues. Framing is transport-specific and is not part of the method contract. Protocol 1.3+ uses one JSON message per WebSocket message for the event data plane.
Bootstrap with system.hello
system.hello must be the first successful request on each transport connection.
Example request:
Example response:
The handshake creates a protocol connection and returns a connectionId. It does not connect to, select, or lease a device. Those lifecycle operations remain explicit device methods after a successful handshake.
Protocol version negotiation
A wire version is an explicit {major, minor} pair and is independent from the daemon, client, or crate SemVer version.
Each side offers an array of ranges:
The array supports multiple, non-contiguous majors. For example, offering majors 1 and 3 does not imply support for major 2.
Negotiation follows these rules:
- Reject an empty offer or any range where
minMinor > maxMinor. - Intersect client and server ranges only when their
majorvalues match. - Select the lexicographically highest compatible
{major, minor}pair. - If no pair is compatible, return
protocol_version_incompatibleand keep the connection inAwaitingHelloso the client may retry.
An incompatibility includes both offers and one stable reason: clientTooOld, serverTooOld, or noCommonVersion.
Feature negotiation
Feature negotiation applies only to optional protocol extensions. Feature names are versioned, for example events.snapshot.v1. Core device observation and action execution are not feature flags; the driver's action space describes those capabilities after the handshake.
Clients split their offer into two sets:
required: every named feature must be available or the handshake fails withrequired_feature_unsupported;optional: supported names are enabled and unsupported names are ignored.
The hello result returns the complete deterministic enabled set. Transport constraints may further reduce the server's effective feature set.
Methods belonging to an extension are visible only when that feature was negotiated. In v1, events.list, events.clear, session.export, and sessions.list require events.snapshot.v1; otherwise they return method_not_found with requiredFeature details.
Protocol 1.4 adds the optional session.export.page.v1 extension on top of
events.snapshot.v1. A legacy {sessionId?} request still returns exactly the
complete {session, events} result. Supplying limit (1–1000), with optional
afterSequence, requires the paging Feature and returns an immutable page of
an ended Session. A non-final page includes nextAfterSequence equal to its
last event sequence; a final page omits it. afterSequence without limit, an
active Session, an invalid limit, or a cursor beyond the log fails explicitly.
Protocol 1.5 adds three optional extensions, with one explicit dependency.
observation.uiSnapshot.v1 lets an Observation reference one bounded,
normalized UI tree stored as typed Evidence; ui.snapshot.get resolves only a
reference reachable from the connection's current active Session.
device.semanticActions.v1 defines the canonical findElement, tapElement,
clearElement, setElementValue, and waitForElement Action contracts plus
explicit native, web, or coordinate-fallback execution metadata. Drivers must
advertise those Actions before use. It may be enabled only together with
observation.uiSnapshot.v1; if the enabled set contains
device.semanticActions.v1 without that dependency, system.hello fails with
JSON-RPC error -32004 and data.code = feature_dependency_unsatisfied.
verdict.record.v1 remains independently negotiable: it validates that every
Evidence reference is reachable from the active Session and persists the
caller's Verdict; the daemon does not calculate the Verdict.
Protocol 1.3 adds events.stream.v1. A negotiated stdio connection calls
events.stream.open to obtain a short-lived, single-use, Session-scoped
loopback bearer endpoint; the endpoint is never diagnostic output. The
WebSocket connection performs its own system.hello, then exactly one
events.subscribe. EventStreamCursor binds streamEpoch, sessionId, and
the last application-confirmed sequence, so a cursor from another Session or
daemon lifetime fails explicitly. The server sends events.stream.event
notifications followed by one closed, typed events.stream.terminal reason.
lastEmittedCursor describes only the server's continuous sent prefix and is
not an application acknowledgement.
Protocol 1.4 adds media.stream.v1. mediaStreamStarted,
mediaFrameCaptured, and mediaStreamEnded form a closed Session-scoped
lifecycle. A frame carries a one-based index, optional timing/key-frame
metadata, and one canonical AssetRef; screenshot/video bytes remain in the
Evidence Store. Stream IDs cannot be reused, frames cannot skip or change
media type, and a Session cannot end with an open stream. The standalone
ManualRecording v1 DTO stores ordered human-selected Action templates and an
ActionSpace digest. Protected templates contain only an opaque secretRef;
the replay host supplies complete protected arguments transiently.
The production control entry is media.stream.start → one or more
media.stream.capture calls → media.stream.end. Start binds a caller-chosen
stream ID to the active Session and selected leased device. Capture accepts a
one-based frameIndex for exact retry and optionally a request timeout; it
internally obtains screenshot Evidence from that device and never accepts
frame bytes, filesystem paths, or caller-provided AssetRef values. A video
stream is a timed sequence of independent PNG key frames, not an encoded video
container, so every video capture requires a positive durationMs.
Protocol 1.2 also adds action.protected.v1. Protected capabilities are
omitted and direct protected execution is rejected unless the connection
explicitly negotiated this Feature. Protected and unknown Action events use a
RecordedActionCall: arguments is null and argumentsRedacted is true.
Ordinary Action event JSON is unchanged. A screenshot-omitted Observation has
no screenshot and carries a typed screenshotOmission reason; an omitted
Action likewise has no screenshot Evidence.
Protocol 1.2 adds device.routing.v1. When negotiated, devices.list returns
the stable device list plus the connection's nullable selectedDeviceId, and
device.select accepts { "deviceId": <DeviceId> } and returns the selected
DeviceInfo. Selection belongs to one connection. A device request captures
its selected route when accepted, so a later selection cannot redirect work
already in flight. These two routing administration methods do not accept
timeoutMs.
When exactly one device is registered, device calls from an unselected legacy
connection route lazily to that sole device. With multiple devices, an
unselected call returns device_selection_required; with no registered device,
it returns device_not_found. Selecting an unknown ID also returns
device_not_found and preserves the connection's previous selection.
Protocol 1.1 adds the request.control.v1 extension; it is not advertised when
the selected wire version is 1.0. After negotiation, timeoutMs is accepted on
device.connect, device.disconnect, device.capabilities, device.observe,
and device.execute. Protocol 1.4 extends the same request control to
media.stream.capture. system.hello cannot use it because negotiation has
not completed, and atomic Session/Event administration methods reject it explicitly.
Both timeoutMs and device.execute.params.actionTimeoutMs are positive
JavaScript-safe integer milliseconds. timeoutMs is the absolute budget for
request-scoped device work, including time before the Driver is called;
actionTimeoutMs starts after actionStarted is durable and covers only Driver
execution. When both apply, the earlier deadline wins. Once an Action has
started, terminal event finalization is shielded from caller cancellation so a
timeout can never leave a half-open Action; that bounded cleanup may finish
after the device-work deadline.
request.cancel accepts { "requestId": <RpcId> } and returns the same ID with
one deterministic status: requested, alreadyRequested, or notFound. A
cancel request has its own RPC ID; it never reuses the target request ID.
Cancellation targets the concurrent device requests listed above. A completed
request reports notFound, even while its response ID remains reserved until
the response has entered the bounded output queue.
Sessions and replayable events
Device operations that produce observations or action facts require an active Session:
session.startcreates a new Session and appends sequence 1 (sessionStarted).device.observeanddevice.executerecord events correlated with the RPC request and device.events.listaccepts optionalsessionId,afterSequence, andlimit(1–1000); omittingsessionIduses the active Session. Bounded consumers advanceafterSequenceto the last returned event until a short or empty page is observed. Omittinglimitpreserves the original full-suffix behavior.session.endappends the finalsessionEndedfact and seals the log.- Legacy
session.exportreturnsSessionInfoplus the complete ordered event list. With negotiatedsession.export.page.v1, bounded consumers instead advance the returnednextAfterSequenceuntil it is omitted.
Every event has a globally unique eventId, a typed sessionId, a one-based JavaScript-safe sequence, optional requestId/deviceId, atMs, and a nested payload. Action completion uses an explicit succeeded, failed, cancelled, or timedOut outcome. Sequence is authoritative for replay; timestamps are informational.
Session logs are append-only. events.clear never removes individual facts and rejects active Sessions; for compatibility with the early method name, it deletes one complete ended Session. Clients should prefer thinking of this operation as Session deletion.
Connection state
AwaitingHello -- successful system.hello --> Ready(negotiated context)
- Before
Ready, any method other thansystem.helloreturnshandshake_required. - A failed hello leaves the connection in
AwaitingHelloand can be retried. - Calling
system.helloagain after success returnshandshake_already_completed. - Negotiated state belongs to one transport connection.
connectionIdis distinct from future device session or lease identifiers.
Errors
JSON-RPC failures use a numeric envelope code and structured DeviceRail data:
Stable mappings through DR-008 are:
| JSON-RPC code | DeviceRail code | Meaning |
|---|---|---|
-32700 |
parse_error |
Input is not valid JSON. |
-32600 |
invalid_request |
The JSON-RPC envelope is invalid or unsupported. |
-32601 |
method_not_found |
The requested method does not exist. |
-32602 |
invalid_params |
Method parameters cannot be validated. |
-32602 |
feature_not_negotiated |
A control field was used before its Feature was enabled. |
-32602 |
request_timeout_not_supported |
timeoutMs was used on a method that does not support request deadlines. |
-32603 |
internal_error |
The server failed unexpectedly. |
-32000 |
Driver code | A routed Driver rejected or failed the operation. |
-32001 |
handshake_required |
system.hello has not completed. |
-32002 |
handshake_already_completed |
The connection is already in Ready. |
-32003 |
protocol_version_incompatible |
Client and server have no compatible wire version. |
-32004 |
required_feature_unsupported |
A required protocol feature is unavailable. |
-32005 |
session_required |
A recording operation requires an active Session. |
-32006 |
Session/Event Store code | Session lifecycle or append-only storage rejected the operation. |
-32007 |
request_cancelled |
A concurrent device request observed explicit cancellation or shutdown. |
-32008 |
request_timed_out / action_timed_out |
The request or Driver-only Action budget elapsed. |
-32009 |
request_id_in_use |
The same RPC ID is already reserved by an unfinished response. |
-32010 |
too_many_requests |
The connection reached its bounded in-flight request limit. |
-32011 |
device_selection_required / device_not_found |
Device routing cannot resolve a route for this connection. |
-32012 |
response_frame_too_large |
The requested result cannot fit in one bounded NDJSON response frame. |
Clients should branch on error.data.code, not on human-readable message text. Breaking serialized changes require an explicit wire protocol version bump.
Action terminal events use their own stable nested codes:
action_cancelled and action_timeout. session.end returns the Store code
session_busy while an Action is in flight; the Session remains active and the
client may cancel or await the Action, then retry the end request.
Bootstrap scope
The initial handshake intentionally excluded authentication, reconnection/resume, device discovery and leasing, push-event streams, compression, AI planning, and YAML configuration. Protocol 1.2 added discovery and connection-local selection; Protocol 1.3 added loopback resumable event streaming; Protocol 1.4 added Evidence-referenced media frames; Protocol 1.5 adds Evidence-referenced UI trees, semantic Action contracts, and caller-produced Verdict persistence. Remote authentication, compression, assertion/model execution, AI planning, and YAML remain outside the kernel protocol.