Version: 0.4.0 (Draft)
Protocol version string: "0.4"
Date: 2026-07-27
License: Apache-2.0
Labwire is an open protocol for AI-controlled laboratory instruments. It gives
agents, both human-operated software and autonomous AI systems, a universal
way to discover an instrument's capabilities, command it, stream
its measurements, and receive cryptographically signed records of what was
done. The protocol is JSON-RPC 2.0 over WebSocket or stdio, with a capability
discovery model inspired by the Model Context Protocol (MCP). Version 0.4
makes cancellation honest: commands declare what cancel can physically do
(cancel_semantics, §8.3), acknowledgment is distinguished from
settlement, and a cancelled run's signed record states what actually
happened, including the case where nobody can confirm it. Version 0.3
added three things v0.2 could not express: resources (addressable, typed,
readable instrument state, such as a liquid handler's deck), typed
references (parameters that name a resource item rather than carrying an
uninterpreted string), and operator grants (an S3 authorization an agent
structurally cannot produce, bound to a command and to a digest of its exact
parameters).
This document is a draft. It is developed alongside a working reference implementation; §15.2 states exactly which parts of this specification that implementation realizes. Breaking changes are expected before 1.0, and v0.2 already makes some (§18).
The key words MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, RECOMMENDED, MAY, and OPTIONAL in this document are to be interpreted as described in BCP 14 [RFC 2119] [RFC 8174] when, and only when, they appear in all capitals, as shown here.
- Instrument Server (server): a process that exposes exactly one instrument, real or simulated, over this protocol.
- Agent Client (client): a process that connects to an Instrument Server to discover, command, and observe the instrument. Typically an AI agent, an orchestrator, or a human-facing tool.
- Session: one transport connection between a client and a server, beginning when the transport opens and ending when it closes; made operational by initialization (§6).
- Command: a named operation the instrument can perform, declared in the instrument's descriptor (§7) and executed via the command lifecycle (§8).
- Run: a single execution of a command, identified by a
command_id. - Channel: a typed, named stream of measurements (§7, §9).
- Event: a discrete occurrence reported by the server (§11).
- Interlock: a declared safety condition which, while tripped, prevents command execution (§7, §8, §11).
- Resource: a named, URI-identified piece of instrument state, declared
in the descriptor and read with
resource/read(§7.6, §10). - Operator grant: an out-of-band-provisioned authorization for one S3 command with one exact parameter set (§8.6).
- Run manifest: a signed record of a completed run (§13).
All JSON field names defined by this protocol use snake_case. Unless
otherwise stated, unrecognized fields MUST be ignored by both parties
(forward compatibility).
Labwire uses JSON-RPC 2.0 [JSONRPC] messages over a bidirectional transport.
The client issues requests; the server answers with responses and
pushes notifications (command progress, telemetry, events). In v0.2 the
server MUST NOT issue requests to the client, with one exception: ping
(§6.3), which either party MAY send and the receiver MUST answer. The only
client-to-server notification is notifications/initialized.
Agent Client Instrument Server
│ │
│ initialize ───────────────────────────────────▶│
│◀─────────────────────────────── initialize result
│ notifications/initialized ────────────────────▶│
│ │
│ instrument/describe ──────────────────────────▶│
│◀──────────────── result: InstrumentDescriptor │
│ │
│ resource/read {uri} ──────────────────────────▶│
│◀──────────────── result: {revision, index, ...} │
│ │
│ command/submit {command, params} ─────────────▶│
│◀──────────────── result: {command_id, accepted} │
│◀─────────── notifications/command_status (push) │
│ │
│ telemetry/subscribe {channels} ───────────────▶│
│◀──────────────── result: {subscription_id} │
│◀─────────────── notifications/telemetry (push) │
│◀─────────────── notifications/event (push) │
│ │
A typical agent session: initialize → describe → read the resources the descriptor declares → submit a command → watch pushed status until a terminal state → read telemetry → repeat → disconnect.
- Messages MUST conform to JSON-RPC 2.0. The
jsonrpcmember MUST be"2.0". - Request
ids MUST be JSON integers, and MUST NOT be reused within a session by the same sender. - JSON-RPC batch arrays MUST NOT be used.
- A party receiving a request for a method it does not implement MUST respond
with JSON-RPC error
-32601(method not found).
The protocol version is a string of the form "MAJOR.MINOR". This document
specifies protocol version "0.4".
- The client states its protocol version in
initialize. - The server replies with the protocol version it will speak: the highest version it supports that is compatible with the client's request.
- If the client does not support the version in the server's reply, the client MUST close the connection.
- Servers SHOULD accept any client version that shares their MAJOR version.
For MAJOR version 0, the MINOR version carries compatibility significance:
servers SHOULD reply with exactly
"0.4"if they implement this document.
The specification document itself is versioned MAJOR.MINOR.PATCH
(this document: 0.4.0); PATCH revisions never change the wire protocol.
Labwire defines two transports. A conforming server MUST implement at least one. All transports carry UTF-8 encoded JSON-RPC messages; framing is the transport's job, and exactly one JSON-RPC message occupies one frame.
- Each JSON-RPC message MUST be sent as one WebSocket text frame containing one JSON object.
- Binary frames are reserved for future use (bulk data). In v0.2 a receiver MUST ignore binary frames.
- WebSocket protocol-level ping/pong frames MAY be used for keepalive by either party.
- Servers MAY serve plaintext
ws://on loopback or isolated lab networks; deployments crossing any network boundary SHOULD usewss://(see §14). - Port 9520 is the RECOMMENDED default port. This is a convention, not a requirement.
- The server communicates over its standard input/output streams: the client writes to the server's stdin, the server writes to the client's stdout.
- Messages are newline-delimited JSON: one JSON-RPC message per line,
terminated by
\n. A message MUST NOT contain unescaped embedded newlines. - The server MUST NOT write anything to stdout that is not a protocol message. Logging MUST go to stderr.
- The JSON-RPC
pingrequest (§6.3) provides liveness where WebSocket ping/pong is unavailable.
The first message in a session MUST be an initialize request from the
client. Initialization completes when the server receives
notifications/initialized. Requests other than ping received before
that point MUST be rejected with error -32002 (busy), with
retryable: false (§12.1). An initialize request received after
initialization has completed MUST be rejected with -32600 (invalid
request).
initialize params:
protocol_version(string, REQUIRED): the client's protocol version.client_info(object, REQUIRED):{name, version}identifying the client software.capabilities(object, REQUIRED): reserved for client capability flags; MAY be empty in v0.2.api_key(string, OPTIONAL): see §14.
initialize result:
-
protocol_version(string, REQUIRED): the version the server will speak (§4). -
server_info(object, REQUIRED):{name, version}identifying the server software. -
capabilities(object, REQUIRED): server capability flags. Defined in v0.3:telemetry(boolean),events(boolean),manifests(boolean: the server produces signed run manifests, §13),resources(boolean: the server answersresource/read, §10), andgrants(boolean: the server holds an operator grant store, §8.6). Absent flags default tofalse. A request for a method belonging to a capability the server advertised asfalseMUST be rejected with-32001(unsupported).A server that declares any
S3command and advertisesgrants: falseis non-conforming and MUST refuse to start: a server with hazardous commands and no way to authorize them is misconfigured, not permissive. Likewise a server whose commands carryresource_refdeclarations (§7.2) MUST advertiseresources: true.
After receiving the result, the client MUST send the
notifications/initialized notification before any other message. The
session is then operational.
There is no shutdown method. Either party ends the session by closing the
transport. On close, the server MUST treat all of the session's telemetry
subscriptions as cancelled. Commands already accepted continue to execute
(instruments are physical processes); their terminal states are simply no
longer observable in this session. They are recoverable via command/status
in a future session only if the server persists run state, which v0.2 does
not require.
The ping request (empty params object) MUST be answered with an empty
result object as soon as it is received, at any point in the session,
including before initialization. Either party MAY send ping to detect a
stalled peer; it is the sole request a server may issue (§3).
A server MAY accept multiple simultaneous sessions. Session-scoped rules:
notifications/command_statusfor a run is delivered only to the session that submitted it.command/statuspolling MUST work from any session that presents thecommand_id.- Telemetry notifications are delivered only to the subscribing session.
- Events (§11) are delivered to every operational session.
max_concurrent_commands(§8.4) is a per-instrument limit shared across all sessions.
The instrument/describe request (empty params) returns the
InstrumentDescriptor: everything a client needs to operate the instrument
without out-of-band knowledge.
-
identity(object, REQUIRED):manufacturer(string, REQUIRED)model(string, REQUIRED)serial_number(string, REQUIRED)firmware_version(string, REQUIRED)firmware_hash(string, OPTIONAL):"sha256:<hex>"of the firmware image, when known. Simulated instruments SHOULD hash their implementing code's version identity instead.
This identity object is embedded verbatim in run manifests (§13).
-
commands(array, REQUIRED): see §7.2. -
channels(array, REQUIRED): see §7.3. -
interlocks(array, REQUIRED): see §7.4. -
resources(array, REQUIRED): see §7.6.[]when the instrument exposes no resources; an instrument with no tree-shaped state loses nothing by saying so. There is deliberately noresources/listmethod: an instrument's resources are as much a property of its kind as its commands are, so they arrive insideinstrument/describe, the request every client already makes, and discovering them is not a step an agent can skip. -
max_concurrent_commands(integer, OPTIONAL, default1): how many commands the instrument executes simultaneously (§8.4).
Each entry in commands:
-
name(string, REQUIRED): unique within the instrument. Names beginning withx-are reserved for vendor extensions and MUST take the formx-<vendor>/<name>(§7.5); all other names are instrument-defined. -
title(string, REQUIRED): short human/agent-readable label. -
description(string, REQUIRED): what the command does, in enough detail for an agent to decide when to use it. -
params_schema(object, REQUIRED): a JSON Schema (draft 2020-12) object describing the command'sparams. The schema MUST be closed: every object within it MUST declare"additionalProperties": false(or name the schema its extra members follow), and every array MUST declare itsitems. An open schema permits a member nobody declared, and an undeclared member can be a quantity, so an open schema silently reopens the unit hole this section exists to close. The empty schema{}constrains nothing and MUST NOT be used; commands that take no parameters declare{"type": "object", "additionalProperties": false}. The same requirement applies toreturns_schema.Typed references. A string-typed schema node inside
params_schemaMAY carry aresource_refkeyword declaring that its value names an item of a resource rather than being an uninterpreted string:{ "type": "string", "resource_ref": { "kind": "container", "enumerated_by": "labwire:deck" } }Both members are REQUIRED.
kindis a registered or vendor-prefixed kind name (Appendix A) matched against the resolved entry'skindsarray (§10.2).enumerated_byis the URI of a resource declared in this descriptor whoseitem_kindscontainskind; a declaration violating either condition is invalid, and servers MUST refuse to serve it. The keyword rides inside the schema deliberately:params_schemais the object that travels verbatim into agent tool schemas, so the pointer to where valid values live reaches the agent at the exact parameter it cannot fill, with no side table for an adapter to forget. Unknown keywords are ignored by ordinary JSON Schema validators, so the schema stays legal draft 2020-12.A node carrying
resource_refMUST NOT also declare apattern: a pattern is satisfiable by invention, which is precisely the failure typed references exist to remove.resource_refis permitted only insideparams_schema, not inreturns_schemaor channel declarations, in v0.3. Reference values are validated against current resource state at submission (§10.4); the semantics of that check, and the error a failure produces, are protocol-defined so the reference vocabulary is shared by every instrument rather than invented per bridge. -
unit_annotations(object, REQUIRED): maps parameter name → UCUM case-sensitive unit code (e.g."mL/min","Cel","g","V"). Every parameter that carries a number MUST have an entry, and dimensionless quantities MUST use"1"(UCUM unity). Commands whose parameters carry no numbers declare{}. Non-numeric parameters MAY be annotated but are not required to be.A parameter carries a number if a
numberorintegercan appear anywhere in a conforming instance, not only when the parameter is itself one. In particular this includes arrays of numbers, arrays of arrays, fixed-length tuples (prefixItems), mappings whose values are numbers (additionalProperties), and any of these reached throughanyOf,oneOf,allOf, or a local$refinto$defs. Atypegiven as a list containingnumberorinteger, and aconstorenumpinning numeric values, also count. The unit belongs to the quantity, not to the container it arrived in: a command taking eight volumes as an array is annotated exactly like a command taking one.A parameter whose type is an object with numeric fields cannot be annotated under this scheme, because
unit_annotationsis keyed by parameter name and one code cannot describe fields of different kinds. Such a declaration MUST be rejected rather than served unannotated; flatten the fields into separate parameters. Per-path unit annotation is a candidate for a future version.Servers MUST reject their own malformed declarations at startup rather than serve an under-annotated descriptor; clients MAY reject a descriptor that violates this rule.
-
returns_units(object, REQUIRED): the same mapping, under the same definition of carrying a number, for the command's result. A command whose result carries numbers without naming them (a bare number, an array, or a mapping) MUST declare at least one code.{}when the command returns nothing numeric. -
qudt_quantity_kind(object, OPTIONAL): maps the same parameter or result names to a QUDTquantityKindIRI or local name (e.g."VolumeFlowRate"), for consumers doing dimensional reasoning. -
safety_class(string, OPTIONAL, default"S1"): one of"S0","S1","S2","S3"; see §8.6. -
returns_schema(object, OPTIONAL): JSON Schema for the command'sresultvalue. -
estimated_duration_s(number, OPTIONAL): typical wall-clock duration. Clients SHOULD use it to choose timeouts, and SHOULD apply their own default timeout when it is absent. -
cancel_semantics(string, OPTIONAL, default"none"): whatcommand/cancelcan honestly do to this command once it is running (§8.3). One of:"abort": the backend has a real halt path; cancellation may interrupt the physical operation, and the server can confirm whether the halt happened."between_steps": the handler issues a sequence of backend operations; cancellation finishes the operation in flight and stops at the next boundary. It never interrupts a step."none": once running, the command runs to completion. The physical action is already committed (on the wire to the device, or simply not stoppable), and pretending otherwise would be a lie. The default is deliberately the safe one: a command that does not say what cancel means cannot be cancelled mid-run. v0.3'sinterruptibleboolean is REMOVED; a boolean could not distinguish aborting from stopping between steps, and its reference implementation abandoned the in-flight backend call, reportingcanceledwhile hardware kept moving (SPEC-FINDINGS F10).
-
clears_interlocks(array of strings, OPTIONAL): declared interlock names this command can clear. See §8.5: such a command remains submittable while a named interlock is tripped.
Why units are mandatory. An agent that cannot tell microlitres from millilitres cannot be trusted with a syringe. Making the unit code a declaration requirement rather than a documentation convention means an agent, or a schema validator, can refuse an ambiguous instrument instead of guessing. Labwire v0.1 treated units as optional prose; v0.2 does not. This follows LAP's mandatory-UCUM design (PRIOR_ART.md).
Unit codes are validated for presence, not grammar: v0.2 servers MUST require a non-empty string but are not required to parse UCUM syntax. Full UCUM grammar validation is a roadmap item.
Command results MUST be JSON-serializable values. This is what allows a
Labwire command to be exposed as a tool by agent frameworks with at most a
name mapping (e.g. MCP tools, where params_schema maps directly to a
tool's input schema; tool-name character sets may require renaming
Each entry in channels:
name(string, REQUIRED): unique within the instrument.description(string, REQUIRED).dtype(string, REQUIRED): one of"float64","int64","bool","string". Values ofint64channels MUST fit the IEEE-754 exactly-representable integer range (|v| ≤ 2^53 − 1). Non-finitefloat64values (NaN, ±Infinity) MUST NOT be sent as samples; servers MUST either suppress such samples or report the condition as an event.unit(string, REQUIRED): the channel's UCUM case-sensitive unit code, a non-empty string. Use"1"(UCUM unity) for dimensionless channels. Presence is normative; grammar validation is not (§7.2).qudt_quantity_kind(string, OPTIONAL): QUDTquantityKindfor this channel.sample_rate_hz_hint(number, OPTIONAL): the natural production rate.
Each entry in interlocks:
name(string, REQUIRED): unique within the instrument.description(string, REQUIRED): the condition it protects against and how it clears.kind(string, REQUIRED):"hard"(trips autonomously in the instrument; cannot be cleared over the protocol) or"soft"(may be clearable by an instrument-defined command, seeclears_interlocks, §7.2).tripped(boolean, REQUIRED): whether the interlock is tripped at the time theinstrument/describeresponse is produced. Consumers MUST treat this as a snapshot, kept current only via theinterlock/trippedandinterlock/clearedevents (§11).
Interlock behavior is specified in §8.5 and §11.
Command names and event names beginning with x-<vendor>/ (e.g.
x-sim/inject_fault) are vendor extensions; the x- prefix is reserved
exclusively for this form. Servers MAY expose them; clients MUST NOT assume
their presence. Extension commands MUST still be declared in commands with
full schemas.
A resource is addressable, typed, readable instrument state: the deck of
a liquid handler, the installed syringe of a pump. Commands describe what an
instrument can do; resources describe what exists to do it to. Each
entry in resources:
-
uri(string, REQUIRED): the resource's identifier, unique within the instrument. See §10.1 for the scheme. -
kind(string, REQUIRED): what the resource is, from the registry (Appendix A) or vendor-prefixed (<vendor>.<name>). -
title(string, REQUIRED): short human/agent-readable label. -
description(string, REQUIRED): what the resource contains, what its index enumerates, and when it changes, in enough detail for an agent to decide when to read it. Servers SHOULD state here which command parameters draw their valid values from this resource's index. -
item_kinds(array of strings, REQUIRED): every kind that can appear in this resource's index (§10.2), so the closure ofresource_refdeclarations is checkable from the descriptor alone.[]for a resource with no index. -
revision(string, REQUIRED): the revision at the time the descriptor was produced, a snapshot exactly asinterlocks[].trippedis (§10.3). -
content_schema(object, REQUIRED): a JSON Schema (draft 2020-12) object describing thecontentmember of a read result. The closed-schema requirement of §7.2 applies unchanged.Units inside content. Every schema node in
content_schemathat describes anumberorintegerMUST carry aunitkeyword holding a UCUM case-sensitive code ("1"for dimensionless):{ "type": "number", "unit": "uL" }Resource content is state, state carries quantities, and shipping a units-optional state format inside a units-mandatory protocol would reopen the hole §7.2 closed, one surface over. The
unitkeyword is scoped tocontent_schemain v0.3: it is NOT permitted inparams_schemaorreturns_schema, whose units remain declared inunit_annotationsandreturns_units. Two annotation schemes exist, but they apply to disjoint surfaces and neither is optional, so there is never a question of which one to use. Unknown keywords are ignored by ordinary JSON Schema validators, so the schema stays legal draft 2020-12. The termunit, and the placement of semantics inside the data schema rather than in a side table, follow W3C Web of Things Thing Description practice (§17).
Resources are read-only in v0.3. Anything that changes instrument state
remains a command, so every state change stays classed, confirmed,
recorded, and signed; set_well_volume remains a command. A server MUST
refuse to start if any resource_ref in its commands names a resource not
declared here, or a kind absent from that resource's item_kinds: the
graph from parameter to kind to enumerating resource is provably closed
before a descriptor is ever served.
A run moves through these states:
┌──────────┬────────────► succeeded
│ │
accepted ──► running ──────────► failed
│ │ │ ▲
│ └──────────┴► canceling ──┤
│ │ │
└────────────────────────┼──────┘
└────► canceled
accepted: validated and queued for immediate execution.running: executing.canceling: cancellation initiated; outcome not yet determined.succeeded,failed,canceled: terminal states. A run in a terminal state MUST NOT change state again.
Legal transitions: accepted → running | canceling | failed;
running → succeeded | failed | canceling;
canceling → canceled | succeeded | failed. The direct edges out of
accepted cover cancellation before start (§8.3) and interlock abort
(§8.5).
command/submit params: command (string, REQUIRED: a declared command
name), params (object, REQUIRED, validated against the command's
params_schema; MAY be {}), confirmation (string, OPTIONAL, required
for S2 commands, see §8.6), authorization (object, OPTIONAL, required
for S3 commands, see §8.6: {"grant_id": "<id>"}), and if_revision
(object, OPTIONAL: maps resource URI → the revision the client planned
against, see §10.5).
Submission checks run in the precedence order of §12.1. An undeclared
command name MUST be rejected with -32001 (unsupported); params
violating the command's params_schema with -32000 (validation); a
reference value that does not resolve with -32010 (unknown_reference,
§10.4); a stale if_revision with -32012 (stale_revision, §10.5); a
missing or unacceptable confirmation or authorization with -32009
(confirmation_required) or -32011 (authorization_required) (§8.6).
In all these cases the server MUST NOT create a run. Otherwise the server
assigns a command_id (string, unique per instrument, RECOMMENDED: UUIDv4)
and responds {command_id, status: "accepted"}.
Normalized parameters. From validation onward the server MUST use the
post-validation parameter object, with schema defaults applied, as the
parameters of the run: it is what handlers receive, what the manifest
records as command.params (§13.1), and what the authorization digest is
computed over (§8.6). The digested thing and the recorded thing therefore
cannot disagree, and an auditor can recompute the digest offline from the
bundle. confirmation, authorization, and if_revision are envelope
fields, not parameters: they are never part of the normalized object or
the digest, so re-reading a resource after an operator approves a call
cannot invalidate the approval.
Status is push-first. On every state transition out of accepted, the
server MUST send notifications/command_status to the submitting session
(§6.4). The server MUST write the command/submit response before any
notifications/command_status for that run; the transition into
accepted is reported only by the submit response. A run's terminal
notifications/command_status MUST be sent after all telemetry and events
attributed to that run. The notification carries the CommandStatus
object:
command_id(string, REQUIRED)status(string, REQUIRED): a state from §8.1.progress(object, OPTIONAL):{fraction?, message?};fractionis 0.0-1.0. Servers MAY additionally sendrunningnotifications that change onlyprogress.result(any, OPTIONAL): present iffstatusissucceeded; conforms to the command'sreturns_schemaif declared.error(object, OPTIONAL): an Error object (§12.2); present iffstatusisfailed, except that servers MAY additionally attach an error with categorycanceled(code-32006) tocanceledruns.cancellation(object, OPTIONAL): present on a terminal status iff a cancel was accepted for this run; see §8.3 for its fields and the claims it may and may not make.resource_revisions(array, OPTIONAL): on a terminal status, the resources this run changed, as[{uri, revision}]with each resource's revision after the run. This is the write-returns-the-new-revision pattern of HTTP conditional requests (§17): an agent that submits every change itself never needs to re-read a resource between steps, because each terminal status hands it the revision to plan the next step against.
command/status params {command_id} MUST return the current
CommandStatus; unknown command_id → error -32000 (validation).
Polling MUST work even though push is required, so that simple clients need
no notification handling. To keep that guarantee meaningful, the server
MUST retain a run's terminal CommandStatus at least until the session that
submitted it closes; cross-session persistence is not required (§6.2).
command/cancel params: {command_id}.
Acknowledgment is not settlement. A command/cancel reply of
canceling means the server accepted the request; it claims nothing
about the physical world. Settlement is the run's terminal status, and
its cancellation block (below) states what actually happened. This
distinction exists because on real instruments a stop request returning
does not mean motion stopped (SPEC-FINDINGS F10).
Acceptance rules:
- A run in
accepted(not yet running) MAY be cancelled regardless ofcancel_semantics: dequeuing is not interruption. The server MUST reply with the current CommandStatus (typicallycanceling) and settle itcanceled. - A
runningrun whose command declares"abort"or"between_steps": the server MUST initiate cancellation per the declared semantics and reply with the current CommandStatus. The terminal state (canceled, orsucceeded/failedif completion won the race) arrives vianotifications/command_status. - A
runningrun whose command declares"none": the server MUST reply with error-32007(not_cancelable), withdetails.cancel_semantics: "none"anddetails.state: "running". A server MUST NOT accept such a cancel and ignore it: refusal is the only honest answer. - A run that is already terminal or in
canceling: error-32007, withdetails.statenaming the state. An unknowncommand_id→ error-32000(validation), as incommand/status. Cancellation is therefore idempotent-safe, and "no such run" remains distinguishable from "cannot cancel".
Settlement. Any run that accepted a cancel MUST carry a
cancellation object on its terminal CommandStatus and in its signed
manifest (§13.1):
requested_at(string or null): when acommand/cancelwas accepted, or null when the run ended by cancellation no client ever requested (server shutdown, a handler-initiated stop).outcome(string, REQUIRED):"never_started": the cancel arrived before the handler ran; nothing was ever issued. The one outcome the server itself can confirm."halted": the backend CONFIRMED the physical stop. Only an"abort"command can settle this way, and only on positive confirmation."halted_at_boundary": a"between_steps"command finished its in-flight step and stopped AT a declared boundary checkpoint. The stop must have originated at the checkpoint itself; a handler that went past a boundary and then gave up mid-step MUST settle"unconfirmed"instead, whatever boundaries it passed earlier."ran_to_completion": the run concluded on its own terms while a cancel was pending; the terminal status issucceededorfailed."unconfirmed": the run ended because of cancellation but nothing confirmed the physical state: a stop the backend never acknowledged, a handler terminated from outside, a crash while a cancel was pending. This outcome is not a failure of the protocol; it is the truth, and servers MUST use it rather than guessing.
boundary(object, present iffoutcomeis"halted_at_boundary"):{completed_steps (integer), of_steps (integer or null), last (string)}, the last step that completed.detail(string, OPTIONAL): free text, e.g. what the backend said or failed to say.
A terminal status of canceled asserts only that the run ended because
of cancellation; the physical claim lives entirely in
cancellation.outcome. EVERY canceled terminal status MUST carry the
block, however the run came to be cancelled, including cancellations no
client requested; a succeeded or failed run that had accepted a
cancel MUST carry it too. A signed manifest MUST NOT contain a
canceled status without a cancellation block, and MUST NOT claim
"halted" without backend confirmation.
An instrument executes at most max_concurrent_commands (§7.1) runs
simultaneously. While at capacity, the server MUST reject command/submit
with error -32002 (busy). The protocol defines no server-side
queueing: queueing, retry, and scheduling are client (agent) policy. The
capacity busy error is retryable (§12.2); servers MAY include
details.retry_after_s (number) as a backoff hint.
While any declared interlock is tripped (§11):
- New
command/submitrequests MUST be rejected with error-32003(interlock): except that a command whoseclears_interlocks(§7.2) names a currently tripped interlock MUST be accepted. This is what makes soft-interlock recovery possible over the protocol. - Runs in
accepted,running, orcancelingMUST transition tofailedwith an error of categoryinterlock, unless the instrument can safely complete them (instrument-defined; completing is the exception, failing is the rule).
The server MUST emit interlock/tripped and interlock/cleared events
(§11). How an interlock clears is instrument-defined and MUST be stated in
its description (e.g. a hard interlock clears only at the instrument;
a soft interlock clears via a declared command).
Every command carries a safety_class (§7.2). The taxonomy is adopted from
LAP (arXiv:2606.03755; see
PRIOR_ART.md) so that instruments and agents crossing
between the two protocols classify actions the same way:
| Class | Meaning | Requires |
|---|---|---|
S0 |
Emergency or protective operations (stop, vent, clear). Always permitted. | nothing |
S1 |
Routine and reversible (read a value, set a setpoint). Default. | nothing |
S2 |
Costly or irreversible (consumes reagent, destroys a sample). | confirmation |
S3 |
Hazardous, capable of harming people or equipment. | an operator grant |
In v0.2 the two upper classes were gated by the same confirmation string,
so classifying a command S3 changed what was printed and recorded and
nothing about what was permitted. v0.3 makes them different mechanisms:
S2 takes a session confirmation an agent can hold; S3 takes a grant an
agent structurally cannot produce.
Normative rules common to both:
- A server MUST NOT require confirmation or authorization for
S0, and SHOULD NOT forS1. - Servers MUST NOT downgrade a command's declared class at submission time.
S0commands MUST remain submittable while an interlock is tripped (§8.5), since they are the means of recovery. (A command that clears an interlock therefore normally declaresS0and lists it inclears_interlocks.)
S2: confirmation. Servers MUST reject a command/submit for an S2
command that carries no acceptable confirmation value, with error
-32009 (confirmation_required), retryable: false, and
data.details.safety_class set to "S2". What counts as acceptable is
deployment policy: a conforming server MAY accept any non-empty string,
MAY compare against a configured token, or MAY implement a stronger
scheme. A standing confirmation for a session of routine S2 work is the
intended pattern.
S3: operator grants. An operator grant is a record in a server-side grant store, provisioned out of band (configuration or environment, e.g. a directory the server reads and an operator tool writes). Normative:
- The protocol MUST NOT provide any method that creates, modifies, extends, enumerates, or reveals grants, and a conforming implementation MUST NOT add one as a vendor extension. Whatever an agent can do over this protocol, minting authorization is not part of it.
- A grant binds, at minimum: the instrument's
serial_number, onecommandname, oneparams_digest, a validity window ([not_before, expires_at)), and a use limit (max_uses, with a persistent use count).params_digestis"sha256:" + lowercase-hex(SHA-256(JCS(params)))over the normalized parameter object of §8.2, canonicalized per RFC 8785. Binding an operator authorization to the capability and to a digest of its canonical parameters is LAP's design (arXiv:2606.03755), adopted here with credit; LAP binds a JWS operator token, and v0.3 keeps the binding while deferring the signature (§14). - A
confirmationvalue MUST NOT satisfy anS3command, whatever it contains. - On an
S3submit that fails authorization, the server MUST reject with-32011(authorization_required),retryable: false, anddata.detailscarrying at minimum:safety_class,command, areasonfrom the enum below,params_digest,digest_alg,canonicalization, andmintable_by_agent: false. Before refusing a submission whose only failure is a missing grant, the server SHOULD record a pending authorization request, capped in number and expiring, holding the command name, the normalized parameters verbatim, the digest, and the instrument identity, and SHOULD include itsrequest_idand a server-configuredoperator_instructionin the error details. A pending request is a description of a request, not an authorization: recording one grants nothing. It exists so the operator's approval tool reads the parameters from the server's own store, never from a digest relayed through the agent that wants the approval. reasonis one of:absent(noauthorization, or aconfirmationoffered instead),unsupported_scheme,unknown,command_mismatch,params_mismatch,instrument_mismatch,not_yet_valid,expired,exhausted,revoked.params_mismatchis the reason that proves the binding is to parameters rather than an S3-shaped password: a valid, unexpired grant for the same command still fails on different values.- On success the server MUST atomically consume one use (increment and persist the count) before creating the run; two concurrent submits MUST NOT both spend the last use of a grant, and a restart MUST NOT resurrect a spent one. Expiry remains the durable bound if the store is lost.
- A grant id is a bearer value. Servers SHOULD generate ids with at least 128 bits of entropy, and MUST NOT write a grant id into any durable artifact (§13.1 records a digest of it instead).
What a grant proves. A verified grant proves that someone with write
access to this server's grant store approved this command name with these
exact parameter values, as the server itself recorded and displayed them,
within a time window and a bounded number of uses. It does not prove
who that person was, that the presenter is that person, or that anyone was
physically present: issued_by and note fields in a store are labels,
not authenticated identity. Cryptographic operator identity, a JWS profile
with key distribution and revocation, remains future work (§14,
ROADMAP.md). v0.2 proved deployment policy; v0.3 proves
deployment policy plus parameter binding plus a bounded window, and
still not identity. Deployments where identity matters should not treat a
v0.3 grant as an audit control over who.
telemetry/subscribe params:
channels(array of strings, REQUIRED): declared channel names. An undeclared name → error-32000(validation), and no subscription is created.max_rate_hz(number, OPTIONAL): per-channel ceiling on delivery rate. Servers SHOULD honor it by dropping intermediate samples.
Result: {subscription_id} (string, unique per session). Servers MUST
support multiple concurrent subscriptions per session, including
overlapping channel sets. A server whose telemetry capability is false
MUST reject telemetry/subscribe with -32001 (unsupported) (§6.1).
telemetry/unsubscribe params {subscription_id} → empty result {}.
Unknown subscription_id → error -32000 (validation). Subscriptions end
at unsubscribe or transport close.
For each sample delivered to a subscription, the server sends
notifications/telemetry:
subscription_id(string, REQUIRED)channel(string, REQUIRED)seq(integer, REQUIRED): per-channel sample sequence number assigned at production, independent of subscriptions: it MUST increment by exactly 1 for each successive sample the channel produces, so a jump in receivedseqindicates dropped samples. Two subscribers of the same channel observe the sameseqfor the same physical sample.seqis monotonic within one server process lifetime; clients MUST treat a decrease as a server restart, not a gap.timestamp(string, REQUIRED): RFC 3339 UTC with fractional seconds, e.g."2026-07-23T15:30:00.123456Z". This is the measurement time as the instrument knows it.value(REQUIRED): JSON value matching the channel's declareddtype.
Delivery is best-effort: under load or rate limits, servers MAY drop or
coalesce samples, but seq MUST remain monotonic per channel so clients can
detect gaps. Clients MUST NOT assume lossless delivery. Servers MUST deliver
samples for one channel to one subscription in seq order.
Instrument state that is a tree has to live somewhere. The descriptor is static capability discovery; telemetry is unit-bearing scalars in a time series; a deck that changes between runs fits neither, which is why v0.2 implementations smuggled it through ordinary command results that nothing marked as special. Resources give it a first-class home: declared in discovery (§7.6), read with one method, revisioned so staleness is detectable, and indexed so typed references (§7.2) have something protocol-defined to resolve against.
A resource identifier is labwire: followed by a rootless path (RFC 3986
path-rootless):
labwire:deck
labwire:deck/source_plate
labwire:deck/source_plate/A1
The first segment names a resource declared in the descriptor; further
segments name items within it. Segment text containing /, ?, #, or
% MUST be percent-encoded. There is exactly one spelling of any URI: a
server MUST reject an alternative form that would resolve to the same
thing, rather than canonicalizing it.
Child composition is protocol-defined, and instruments MUST NOT define
another. An item URI is <entry-uri> "/" <id>, where <id> comes from
the read result's index (§10.2). This one rule is what keeps addressing
out of per-instrument convention: an agent that can read an index can
construct every legal reference on any conforming instrument, and there is
no grammar to learn or to guess. Ids are enumerated rather than templated
for the same reason: a template is a grammar.
The labwire: scheme is provisional and unregistered.
resource/read params: uri (string, REQUIRED): a resource URI declared
in the descriptor. Reading an item URI is not supported in v0.3; clients
read the resource and join on the index. An unknown, undeclared, or
malformed uri MUST be rejected with -32010 (unknown_reference,
reason: "unknown_resource" or "malformed_uri"), so there is one story
about URIs that do not resolve rather than two.
Result:
uri(string, REQUIRED): as requested.kind(string, REQUIRED): as declared.revision(string, REQUIRED): see §10.3.read_at(string, REQUIRED): RFC 3339 UTC timestamp of this read.index_complete(boolean, REQUIRED): whetherindexenumerates every resolvable reference. A server MAY setfalsefor a resource it cannot enumerate exhaustively; it MUST still resolve references correctly, and a client MUST NOT infer non-existence from absence in an incomplete index.index(array, REQUIRED): the reference index. Each entry:uri(string, REQUIRED): the entry's own URI.kinds(array of strings, REQUIRED): every kind this entry satisfies, most specific first (a trough is["trough", "container", "labware"]). A reference declaring kind K resolves to this entry iff K is in this array; there is no subtyping graph in the protocol, the instrument declares the set.title(string, OPTIONAL): a short label.children(object, OPTIONAL):{kinds, ids}; the entry has one item per id, each with URI<entry-uri>/<id>and the givenkinds. A 96-well plate lists 96 ids rather than a range expression.
content(REQUIRED): instrument-defined state conforming to the declaredcontent_schema(§7.6). Where content describes a referenceable thing it MUST identify it byuri, so a client can join content to index.
A reference value V resolves iff some index entry E has E.uri == V
(satisfying E.kinds), or some entry E has children and
V == E.uri + "/" + id for an id in E.children.ids (satisfying
E.children.kinds).
Resources are read-only; there is no write method (§7.6). No pagination is defined in v0.3; a resource whose index would be impractically large (a 1536-well plate is ~9 KB of ids and is fine; a plate hotel of thousands of positions may not be) is a known open problem recorded in §15.2.
revision is an opaque string that MUST change whenever a read of the
resource would return different index or content, and MUST NOT be
interpreted by clients beyond equality. RECOMMENDED construction is a
per-process nonce plus a counter; the reference implementation derives it
as a truncated hash of the canonicalized read result, which makes "the
driver forgot to bump it" impossible. Reference validation (§10.4) never
consults a revision, so a defective revision can at worst cost a missed
notification or a spurious -32012, never a wrong validation.
Change notification reuses the event channel (§11) under the reserved
name resource/changed, with data: {uri, revision}. Delivery is
best-effort exactly as §11 specifies; the revision in the payload lets a
client discard stale notifications. There is no per-resource subscription:
events are already broadcast to every operational session, and a second
push model for a handful of resources is surface without power. Because
events are written into active run records (§11), a signed manifest's
event stream also witnesses every deck change during the run.
For each string location in the validated params whose schema node
carries resource_ref (including inside arrays), the server MUST resolve
the value against a fresh read of the declared enumerated_by
resource, per §10.2, checking that the resolved entry satisfies
resource_ref.kind. On the first failure the server MUST reject the
submission with -32010 (unknown_reference), retryable: false, and
data.details carrying at minimum: pointer (an RFC 6901 pointer into
params, so the second element of an array is nameable), parameter,
reference (the offending value), expected_kind, enumerated_by, and
a reason from: malformed_uri, unknown_resource, no_such_item,
kind_mismatch. Servers SHOULD add resolved_prefix (the longest prefix
that did resolve) with its resolved_kinds, an OPTIONAL did_you_mean
list (capped, filtered by expected_kind), and read: a literal,
ready-to-send resource/read request object, so "I do not know what to
pass" becomes a call the agent can make without parsing prose.
Validation MUST use current state, not a cache keyed by revision: a
defective revision must not let a reference to moved labware pass. This
check is time-of-check-to-time-of-use: labware can move between validation
and execution, and v0.3 does not close that window (§14). if_revision
narrows it (§10.5).
A client that plans against a resource read MAY assert its plan is still
valid by sending if_revision on command/submit: an object mapping each
resource URI it planned against to the revision it read. For each entry,
the server MUST compare against the resource's current revision and reject
on the first mismatch with -32012 (stale_revision), retryable: false, and data.details carrying uri, submitted_revision,
current_revision, and a ready-to-send read object. No run is created,
no confirmation is consumed, and no grant use is spent: staleness is
checked before authorization precisely so a stale plan never costs an
operator approval.
if_revision is an envelope field, never part of the normalized
parameters or the authorization digest (§8.2): an operator approves an
action, not a snapshot, and re-reading the deck after approval does not
invalidate a grant. A run's terminal status returns the new revisions
(§8.2), so a single agent driving an instrument can maintain freshness
without ever re-reading. What if_revision does not provide is a
reservation: between the check and a concurrent client's next write there
is no lock, and reservation leases remain future work (§14,
ROADMAP.md).
Events report discrete occurrences; telemetry reports sampled values. The
server pushes notifications/event:
name(string, REQUIRED)timestamp(string, REQUIRED): RFC 3339 UTC, as §9.2.severity(string, REQUIRED):"info","warning", or"alarm".data(object, REQUIRED): event-specific payload; MAY be{}.
Reserved event names in v0.3 (servers MUST use these names for these meanings):
| Name | Meaning | data |
|---|---|---|
instrument/state_changed |
Operating state changed | {state}, instrument-defined states |
interlock/tripped |
A declared interlock tripped | {interlock} (its declared name) |
interlock/cleared |
A tripped interlock cleared | {interlock} |
measurement/stable |
A measurement reached stability (e.g. a balance settling) | {channel, value} |
error/occurred |
An error not attributable to one run | Error object (§12.2) |
resource/changed |
A resource's content or index changed (§10.3) | {uri, revision} |
Other event names are instrument-defined; vendor extensions MUST use the
x-<vendor>/ prefix (§7.5). Servers whose events capability is true
MUST deliver every event to every operational session; there is no event
subscription in v0.3. Events MUST be delivered to a session in emission
order; delivery is best-effort, but events of severity alarm SHOULD NOT
be dropped.
Standard JSON-RPC codes apply to protocol-level failures: -32700 (parse
error), -32600 (invalid request), -32601 (method not found), -32602
(invalid params, malformed for the method, e.g. missing command_id),
-32603 (internal JSON-RPC error).
Labwire domain errors use the JSON-RPC server-error range:
| Code | Category | Meaning | Retryable |
|---|---|---|---|
| -32000 | validation |
Params violate a schema, a referenced entity is unknown, or a presented credential is rejected | no |
| -32001 | unsupported |
Command not declared, or method belongs to a capability advertised false |
no |
| -32002 | busy |
At max_concurrent_commands capacity (retryable), or not yet initialized (servers SHOULD set retryable: false for this case) |
yes |
| -32003 | interlock |
Rejected or aborted because an interlock is tripped | no |
| -32004 | hardware_fault |
The instrument reported a hardware failure | no |
| -32005 | timeout |
The instrument did not respond internally in time | yes |
| -32006 | canceled |
The run was canceled | no |
| -32007 | not_cancelable |
Cancel refused: terminal, already canceling, or the command declares cancel_semantics: "none" (details say which, §8.3) |
no |
| -32008 | internal |
Unexpected server error | no |
| -32009 | confirmation_required |
An S2 command was submitted without an acceptable confirmation (§8.6) |
no |
| -32010 | unknown_reference |
A resource_ref parameter value, or a resource/read URI, does not resolve in current resource state (§10.4) |
no |
| -32011 | authorization_required |
An S3 command was submitted without a verifiable operator grant for these exact parameters (§8.6) |
no |
| -32012 | stale_revision |
An if_revision precondition did not match the resource's current revision (§10.5) |
no |
The "Retryable" column is the REQUIRED default for the retryable field;
servers MAY override it per error instance (e.g. a transient
hardware_fault).
When multiple rejection rules apply to one request, precedence is:
not-initialized (-32002) → method not found (-32601) → invalid method
params (-32602) → unsupported (-32001) → validation (-32000) →
unknown_reference (-32010) → stale_revision (-32012) →
interlock (-32003) → capacity busy (-32002) →
confirmation_required (-32009) / authorization_required (-32011).
This order applies one principle consistently: everything knowable
without an operator is checked first, so an agent is never asked to
confirm, and a single-use grant is never spent, on a call that could not
have run. It moves interlock and capacity ahead of confirmation
relative to v0.2, which had already stated the principle for validation.
The reordering cannot deadlock recovery: interlock-clearing commands are
S0 and exempt from the interlock check (§8.5).
Everywhere an error appears, JSON-RPC error member, or CommandStatus
error field: it is:
code(integer, REQUIRED)message(string, REQUIRED): human-readable, one line.data(object, REQUIRED for codes -32000..-32012):category(string, REQUIRED): from the table above.retryable(boolean, REQUIRED): whether the same request MAY succeed if retried without operator intervention. Agents SHOULD key retry policy off this field, not offcode. Clients MUST treat errors lackingdata.retryable(e.g. standard JSON-RPC errors) as not retryable.details(object, OPTIONAL): structured diagnostic payload.
Servers MUST NOT leak stack traces or internal paths in message or
details.
Every run that reaches a terminal state SHOULD produce a run manifest: a portable, verifiable record of what instrument did what, with which parameters, what came out, and how it ended. Manifests make results attributable and tamper-evident.
Servers advertising the manifests capability (§6.1) MUST produce a
manifest for every terminal run. How manifests are surfaced to consumers
is implementation-defined in v0.3: no protocol method carries manifests;
the reference implementation writes a bundle (manifest + record stream) per
run to a local directory. A future protocol version may add a retrieval
method.
Conformance note: the manifest format is normative; the reference implementation produces and verifies these bundles (§15.2).
<!-- example: manifest/document -->
{
"manifest_version": "0.4",
"protocol_version": "0.4",
"run_id": "b7e0a1c2-4d5e-4f60-8a9b-0c1d2e3f4a5b",
"instrument": {
"manufacturer": "Labwire Project",
"model": "SimBalance-120",
"serial_number": "SIM-0003",
"firmware_version": "0.1.0",
"firmware_hash": "sha256:9f2b5c1d8e3a4f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8"
},
"command": {
"name": "measure",
"params": { "settle_timeout_s": 30.0 },
"safety_class": "S1",
"params_digest": "sha256:b8a66f00ce786f5fb861ea0d72562e611c8a0332c7ee5adc2dc88a4a2b527561"
},
"status": "succeeded",
"result": { "mass_g": 12.3456 },
"data": {
"digest_alg": "sha256",
"digest": "306f739d0eb82314ad5783e7e673b8f61c56ebf938f521e80ffd2be0e5991450",
"channels": ["mass"]
},
"timestamps": {
"submitted": "2026-07-23T15:30:00.123456Z",
"started": "2026-07-23T15:30:00.234567Z",
"completed": "2026-07-23T15:30:12.345678Z"
},
"signer": {
"alg": "ed25519",
"public_key": "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo=",
"key_id": "sha256:300c9c9603b92a4b39ed3958bf9240114804db4fd373012c0ca47432d63425ae"
}
}(digest is illustrative; key_id is the genuine SHA-256 of the example
public_key, and params_digest is the genuine digest of the example
params per §8.6.)
All fields are REQUIRED unless marked otherwise:
manifest_version(string):"0.4"for this document. Verifiers MUST also accept"0.2"bundles, which lack the members introduced below; the format change breaks producers, not verifiers.protocol_version(string): the negotiated protocol version (§4).run_id(string): the run'scommand_id.instrument(object): theidentityobject from the descriptor (§7.1), verbatim.command(object):name(string),params(object): the normalized parameters of §8.2, with schema defaults applied, which are also the digest input, andsafety_class(string): the class the server enforced for it (§8.6). In v0.2paramsrecorded the raw submission, so a command with defaulted optionals signed a manifest describing something other than what ran; recording the normalized object closes that, and lets an auditor recomputeparams_digestoffline from the bundle alone.params_digest(string): the digest of §8.6, present for every run in a 0.3 manifest. All are covered by the signature, so a manifest records what safety posture applied to the run.authorization(object, present iff the command's class isS2orS3): how the run was authorized. ForS2:{"mode": "confirmation", "identity_verified": false}. ForS3:modeis"grant", withrequest_id(string, OPTIONAL), anexpires_atanduse_indexdescribing the grant use, free-textissued_byandnotecopied from the store and labelled by this specification as unauthenticated,grant_digest(string):"sha256:"+ hex SHA-256 of the grant id, andidentity_verified(boolean, REQUIRED): MUST befalsein v0.3. The grant id itself MUST NOT appear: it is a bearer value and a signed bundle is a durable artifact.identity_verifiedexists so the honesty caveat of §8.6 is a machine-checkable wire fact rather than prose: verifiers MUST surface it, and a future version with cryptographic operator identity flips it.cancellation(object, present per §8.3's block rules): the settlement block, covered by the signature. A signed manifest MUST NOT record acanceledstatus without it, and MUST NOT recordoutcome: "halted"the backend did not confirm: a manifest that asserts a physical halt the backend cannot vouch for is worse than no manifest at all. Thecommandblock recordscancel_semantics(0.4 manifests) precisely so these rules are auditable offline: a verifier MUST reject a 0.4 manifest whose status iscanceledwith nocancellationblock, whose"halted"outcome sits on a command not declared"abort", or whose"halted_at_boundary"sits on a command not declared"between_steps".resource_revisions(array, OPTIONAL): for each resource the run changed,{uri, revision_at_start, revision_at_end}, so an auditor can ask whether state moved under the run.status(string): the run's terminal state (§8.1).result(any, present iffstatusissucceededand the command returned a value): the command's result, verbatim.error(object, present iffstatusisfailed): the Error object (§12.2).data(object):digest_alg(string):"sha256", the only permitted v0.2 value.digest(string): lowercase hex SHA-256 of the run's record stream, defined below.channels(array of strings): every channel that produced samples during the run window; MAY be empty.
timestamps(object):submitted,started,completed: RFC 3339 UTC (§9.2), from the server's clock.signer(object):alg(string):"ed25519", the only permitted v0.2 value.public_key(string): the 32-byte ed25519 public key, standard base64.key_id(string):"sha256:"+ lowercase hex SHA-256 of the raw 32-byte public key.
Record stream. The digest input is defined independently of subscriptions and rate limits, so any two implementations recording the same run produce identical digests. The record stream is the sequence, in the server's emission order, of:
- for each sample produced on a channel listed in
data.channelswith timestamp in [timestamps.started,timestamps.completed]: the JCS canonicalization (§13.2) of{"type": "sample", "channel": ..., "seq": ..., "timestamp": ..., "value": ...}; - for each event emitted in that window: the JCS canonicalization of
{"type": "event", "name": ..., "timestamp": ..., "severity": ..., "data": ...};
each record followed by exactly one \n (0x0A). Note the record objects
carry no subscription_id: they are production-side records, not
notifications. An empty record stream digests to the SHA-256 of zero bytes
(e3b0c442…b855). Bundles SHOULD include the record stream itself so
verifiers can recompute digest.
The signature is computed as:
- Construct the manifest object without any
signaturefield. - Canonicalize it using the JSON Canonicalization Scheme (JCS) [RFC 8785].
- Sign the resulting UTF-8 bytes with ed25519 [RFC 8032].
The signed bundle is the manifest object plus a top-level signature field:
the 64-byte ed25519 signature, base64url-encoded without padding.
Verification: remove signature, canonicalize per JCS, verify against
signer.public_key, and check signer.key_id matches that key. Verifiers
MUST reject a bundle whose key_id does not match its public_key.
How a verifier comes to trust a public key is out of scope for v0.2. Servers
SHOULD generate a keypair on first run and persist it; operators SHOULD
record the key_id out of band (trust-on-first-use). This is stated plainly:
v0.2 manifests prove integrity (the record wasn't altered) and key
continuity (same signer as before), not identity (who the signer is). See
§14.
v0.3 is designed for trusted environments: localhost or an isolated lab network. Stated plainly:
- Authentication is a stub. The client MAY present
api_keyininitialize(§6.1); a server configured with a key MUST reject initialization on mismatch with error-32000(validation). There is no authorization model, no user identity, and no key rotation in v0.2. - Authorization proves policy and binding, not identity. An
S2confirmationshows that whoever holds the deployment's token permitted this class of action. AnS3grant (§8.6) proves more: someone with write access to the server's grant store approved this command with these exact parameter values, within a window and a use limit. Neither identifies who. A grant id is a bearer value on a transport that only SHOULD use TLS, replayable within its window by anyone who can read the traffic; single use and short expiry bound the damage, and the manifest records only its digest. On a single machine, an operator console and an agent running as the same user are not separated by anything this protocol can enforce; the grant store's write permissions are the actual boundary, and deployments where it matters put the store where the agent cannot write. Cryptographic operator identity: a JWS operator token signed over the task and the hash of its canonical parameters, as LAP specifies (arXiv:2606.03755): is the intended successor and is tracked in ROADMAP.md. Until then, deployments MUST NOT rely onconfirmationor grants as an accountability control over identity;identity_verified: falsein every v0.3 manifest (§13.1) states this on the wire. - Reference validation is time-of-check-to-time-of-use. A reference
resolves at submission against state that can change before or during
execution, and v0.3 provides no lock.
if_revision(§10.5) narrows the window and never closes it; reservation leases are future work. v0.3 ships no lost-update protection: two clients interleaving on one instrument can invalidate each other's plans without either seeing an error, unless both useif_revisionand re-read on-32012. - Transport security. Deployments that cross any network boundary SHOULD
use
wss://(TLS). The protocol itself provides no confidentiality. - Manifest guarantees are limited to integrity and key continuity (§13.3). A manifest does not prove the physical sample, the operator, or the calibration state.
- Agent-facing strings are untrusted input. Descriptor fields
(
title,description, event payloads, error messages) flow into AI agent contexts. Agents and agent frameworks SHOULD treat them as data, never as instructions, and SHOULD NOT execute directives embedded in them. Servers MUST NOT require semantic interpretation of free-text fields for safe operation, safety-relevant behavior belongs in typed fields (interlocks, error categories, states). - Safety interlocks are not security boundaries. A tripped interlock constrains the protocol (§8.5); a malicious server can lie about it. Physical safety MUST be enforced in the instrument, not in this protocol.
| Level | Requirements |
|---|---|
| Core | One transport (§5); initialize, ping, notifications/initialized (§6); instrument/describe (§7); command lifecycle with push status and polling (§8); error taxonomy (§12) |
| Streaming | Core + telemetry (§9) + events (§11) |
| Signed | Streaming + run manifests (§13) |
A server MUST document its level. A client MUST tolerate a server of any
level (the capability flags in the initialize result tell it what to
expect).
Some requirements are capability-conditional rather than level-bound:
they apply, at every level, exactly when the server declares the feature.
A server declaring resources must satisfy §10; a server declaring commands
with resource_ref parameters must satisfy §7.2 and §10.4; a server
declaring S2/S3 commands must satisfy §8.6. A server declaring none of
these owes none of them.
Honesty table, what the reference implementation in this repository implements:
| Spec section | Status |
|---|---|
| §5.1 WebSocket transport | Implemented |
| §5.2 stdio transport | Specified only: no consumer yet; implementation unscheduled |
| §6 session lifecycle, §7 discovery, §8 commands, §9 telemetry, §11 events, §12 errors | Implemented |
| §7.2/§7.3 mandatory UCUM codes | Implemented, presence enforced at declaration and on the wire; UCUM grammar not parsed |
§7.2 qudt_quantity_kind |
Implemented as a pass-through declaration; no QUDT reasoning |
§7.2 typed references (resource_ref) |
Implemented: shape at declaration, closure at server construction, resolution at submission |
| §7.6/§10 resources | Implemented: declaration, resource/read, derived revisions, resource/changed. No pagination; a very large index is an open problem. No caching: every reference validation re-reads |
§8.6 S2 confirmation |
Implemented with a configured-token stub |
§8.6 S3 operator grants |
Implemented: file-backed store, pending requests, atomic use counts, labwire grant. Assumes the store lives where the agent cannot write; nothing in-protocol enforces that |
| §8.6 cryptographic operator identity | Not implemented: identity_verified is false in every manifest; see §14 and ROADMAP.md |
§10.5 if_revision |
Implemented. No reservation, no lost-update protection beyond it |
§8.3 cancel_semantics and settlement |
Implemented: refusal on "none", boundary settlement for "between_steps", "unconfirmed" when a backend cannot confirm a halt |
| §13 signed manifests | Implemented: bundle = manifest.json + records.jsonl, verified by labwire verify; 0.2, 0.3, and 0.4 bundles all verify |
§14 api_key stub |
Deferred, unscheduled |
| In-memory transport (test-only; not a §5 transport) | Implemented |
This table is updated with each release.
labwire-conformance (the packages/conformance distribution) renders
these requirements as executable checks. Each check is binary pass/fail
and names the spec section it tests; the verdict is the highest level of
§15.1 at which every applicable check passes. There are no percentages: a
failed MUST is nonconformance, and the honest statement of partial
progress is the check list itself, not a score.
pip install labwire-conformance
labwire-conformance ws://HOST:PORT --claim coreChecks that would execute a command on the instrument are opt-in
(--exercise COMMAND, on a deployment where that is safe); everything
else stops at refusals the server must issue before running anything. The
signed-manifest checks need --bundle-dir pointing where the server
writes run bundles. A claim of "conformant at level X" made without the
opt-in checks is not supported by the tool, and the report says exactly
which proof is missing.
The suite trusts the reference message models as the executable rendering of §16, and it grows as findings do: passing it proves the checked behaviors, not the absence of every bug. In this repository's CI the suite runs against the reference server on every commit.
Every protocol message, one example each. Examples are normative for shape.
Marker grammar: the first line inside each fenced JSON block is
<!-- example: <name>/<kind> -->, where <name> is a JSON-RPC method name
or one of the literals error and manifest, and <kind> is one of
request, result, notification, notification-terminal, response,
document, signature-excerpt. The reference implementation's test suite
extracts every marked block in this document (including §13.1), strips the
marker line, and round-trips the JSON through the message model registered
for <name>: failing if any example does not round-trip. Blocks whose kind
is signature-excerpt are validated only for the fields present.
Examples are independent snapshots, not one session timeline; id,
command_id, and hash/signature values are illustrative unless stated
otherwise.
<!-- example: initialize/request -->
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocol_version": "0.2",
"client_info": { "name": "labwire-client", "version": "0.1.0" },
"capabilities": {}
}
}<!-- example: initialize/result -->
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"protocol_version": "0.2",
"server_info": { "name": "labwire-sim-pump", "version": "0.1.0" },
"capabilities": { "telemetry": true, "events": true }
}
}<!-- example: notifications/initialized/notification -->
{
"jsonrpc": "2.0",
"method": "notifications/initialized",
"params": {}
}<!-- example: ping/request -->
{ "jsonrpc": "2.0", "id": 2, "method": "ping", "params": {} }<!-- example: ping/result -->
{ "jsonrpc": "2.0", "id": 2, "result": {} }<!-- example: instrument/describe/request -->
{ "jsonrpc": "2.0", "id": 3, "method": "instrument/describe", "params": {} }<!-- example: instrument/describe/result -->
{
"jsonrpc": "2.0",
"id": 3,
"result": {
"identity": {
"manufacturer": "Labwire Project",
"model": "SimPump-100",
"serial_number": "SIM-0001",
"firmware_version": "0.1.0",
"firmware_hash": "sha256:6a3f0c1d8e2b4a5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7"
},
"commands": [
{
"name": "dispense",
"title": "Dispense volume",
"description": "Dispense a volume of liquid at a controlled flow rate.",
"params_schema": {
"type": "object",
"additionalProperties": false,
"properties": {
"volume_ul": { "type": "number", "exclusiveMinimum": 0 },
"rate_ul_min": { "type": "number", "exclusiveMinimum": 0 }
},
"required": ["volume_ul", "rate_ul_min"]
},
"unit_annotations": { "volume_ul": "uL", "rate_ul_min": "uL/min" },
"returns_units": { "dispensed_ul": "uL" },
"qudt_quantity_kind": {
"volume_ul": "Volume",
"rate_ul_min": "VolumeFlowRate"
},
"safety_class": "S2",
"returns_schema": {
"type": "object",
"additionalProperties": false,
"properties": { "dispensed_ul": { "type": "number" } },
"required": ["dispensed_ul"]
},
"estimated_duration_s": 30.0,
"cancel_semantics": "abort"
},
{
"name": "abort",
"title": "Abort motion",
"description": "Stop the motor immediately and clear a stalled line.",
"params_schema": { "type": "object", "additionalProperties": false },
"unit_annotations": {},
"returns_units": {},
"safety_class": "S0",
"clears_interlocks": ["over_pressure"]
}
],
"channels": [
{
"name": "flow_rate",
"description": "Instantaneous flow rate.",
"dtype": "float64",
"unit": "uL/min",
"qudt_quantity_kind": "VolumeFlowRate",
"sample_rate_hz_hint": 10.0
}
],
"interlocks": [
{
"name": "over_pressure",
"description": "Trips when line pressure exceeds the safe limit. Clears when pressure drops below the limit.",
"kind": "hard",
"tripped": false
}
],
"resources": [
{
"uri": "labwire:syringe",
"kind": "consumable",
"title": "Installed syringe",
"description": "The syringe currently installed in the pump: its model, capacity, and how much it holds. Changes when a syringe is exchanged or the plunger moves.",
"item_kinds": [],
"revision": "b2c4e6a8-17",
"content_schema": {
"type": "object",
"additionalProperties": false,
"required": ["model", "capacity_ul", "installed_ul"],
"properties": {
"model": { "type": "string" },
"capacity_ul": { "type": "number", "unit": "uL" },
"barrel_diameter_mm": { "type": "number", "unit": "mm" },
"installed_ul": { "type": "number", "unit": "uL" }
}
}
}
],
"max_concurrent_commands": 1
}
}An instrument with tree-shaped state and typed references declares them together; a fragment of a liquid handler's descriptor:
<!-- example: instrument/describe/result -->
{
"jsonrpc": "2.0",
"id": 3,
"result": {
"identity": {
"manufacturer": "PyLabRobot bridge (Labwire)",
"model": "LiquidHandlerChatterboxBackend",
"serial_number": "dilution-rig",
"firmware_version": "0.3.0"
},
"commands": [
{
"name": "transfer",
"title": "Transfer",
"description": "Move liquid from one container into one or more others, aspirating and dispensing in one command.",
"params_schema": {
"type": "object",
"additionalProperties": false,
"required": ["source", "targets", "volumes_ul"],
"properties": {
"source": {
"type": "string",
"resource_ref": { "kind": "container", "enumerated_by": "labwire:deck" },
"description": "The container to draw from. Must be a container listed in the index of resource labwire:deck; read that resource for the valid values."
},
"targets": {
"type": "array",
"minItems": 1,
"items": {
"type": "string",
"resource_ref": { "kind": "container", "enumerated_by": "labwire:deck" }
}
},
"volumes_ul": {
"type": "array",
"minItems": 1,
"items": { "type": "number", "exclusiveMinimum": 0 }
}
}
},
"unit_annotations": { "volumes_ul": "uL" },
"returns_units": { "total_volume_ul": "uL" },
"safety_class": "S2",
"cancel_semantics": "between_steps"
}
],
"channels": [],
"interlocks": [],
"resources": [
{
"uri": "labwire:deck",
"kind": "deck",
"title": "Deck",
"description": "What is on the deck right now. Every container, tip site, labware and site a command parameter can name is listed in this resource's index. Changes whenever labware or liquid moves.",
"item_kinds": ["labware", "plate", "tip_rack", "container", "tip_site", "site", "trash"],
"revision": "9f3c1a4e-131",
"content_schema": {
"type": "object",
"additionalProperties": false,
"required": ["contents"],
"properties": {
"contents": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["uri", "volume_ul"],
"properties": {
"uri": { "type": "string" },
"volume_ul": { "type": "number", "unit": "uL" },
"max_volume_ul": { "type": ["number", "null"], "unit": "uL" }
}
}
}
}
}
}
],
"max_concurrent_commands": 1
}
}<!-- example: resource/read/request -->
{ "jsonrpc": "2.0", "id": 10, "method": "resource/read", "params": { "uri": "labwire:deck" } }<!-- example: resource/read/result -->
{
"jsonrpc": "2.0",
"id": 10,
"result": {
"uri": "labwire:deck",
"kind": "deck",
"revision": "9f3c1a4e-131",
"read_at": "2026-07-27T09:14:02.115430Z",
"index_complete": true,
"index": [
{
"uri": "labwire:deck/tips",
"kinds": ["tip_rack", "labware"],
"title": "tips",
"children": { "kinds": ["tip_site"], "ids": ["A1", "B1", "C1", "D1"] }
},
{
"uri": "labwire:deck/source_plate",
"kinds": ["plate", "labware"],
"title": "source_plate",
"children": { "kinds": ["container"], "ids": ["A1", "A2", "B1", "B2"] }
},
{ "uri": "labwire:deck/staging-0", "kinds": ["site"], "title": "staging-0" },
{ "uri": "labwire:deck/trash", "kinds": ["trash", "labware"], "title": "trash" }
],
"content": {
"contents": [
{ "uri": "labwire:deck/source_plate/A1", "volume_ul": 300.0, "max_volume_ul": 360.0 }
]
}
}
}(ids arrays abbreviated; a real 96-well plate lists 96 two-character ids,
about 600 bytes.)
<!-- example: command/submit/request -->
{
"jsonrpc": "2.0",
"id": 4,
"method": "command/submit",
"params": {
"command": "dispense",
"params": { "volume_ul": 500.0, "rate_ul_min": 1000.0 },
"confirmation": "operator-standing-grant-2026-07-26"
}
}dispense is declared S2 (§8.6), so the confirmation field is
required; submitting the same request without it is rejected with -32009
(confirmation_required). An S0 or S1 command needs no such field.
An S3 command takes an operator grant instead, and MAY carry
if_revision asserting the resource state the plan was made against
(§10.5). Note the reference-valued parameters and that no confirmation
appears: one would not satisfy S3.
<!-- example: command/submit/request -->
{
"jsonrpc": "2.0",
"id": 18,
"method": "command/submit",
"params": {
"command": "move_plate",
"params": {
"plate": "labwire:deck/dilution_plate",
"to": "labwire:deck/staging-0"
},
"authorization": { "grant_id": "g-7f2a91c4" },
"if_revision": { "labwire:deck": "9f3c1a4e-131" }
}
}<!-- example: command/submit/result -->
{
"jsonrpc": "2.0",
"id": 4,
"result": {
"command_id": "5f0c2f0a-7c1e-4d0b-9a63-2f3a1c8d9e4b",
"status": "accepted"
}
}<!-- example: notifications/command_status/notification -->
{
"jsonrpc": "2.0",
"method": "notifications/command_status",
"params": {
"command_id": "5f0c2f0a-7c1e-4d0b-9a63-2f3a1c8d9e4b",
"status": "running",
"progress": { "fraction": 0.4, "message": "200 of 500 uL dispensed" }
}
}<!-- example: notifications/command_status/notification-terminal -->
{
"jsonrpc": "2.0",
"method": "notifications/command_status",
"params": {
"command_id": "5f0c2f0a-7c1e-4d0b-9a63-2f3a1c8d9e4b",
"status": "succeeded",
"result": { "dispensed_ul": 500.0 },
"resource_revisions": [
{ "uri": "labwire:syringe", "revision": "b2c4e6a8-18" }
]
}
}<!-- example: command/status/request -->
{
"jsonrpc": "2.0",
"id": 5,
"method": "command/status",
"params": { "command_id": "5f0c2f0a-7c1e-4d0b-9a63-2f3a1c8d9e4b" }
}<!-- example: command/status/result -->
{
"jsonrpc": "2.0",
"id": 5,
"result": {
"command_id": "5f0c2f0a-7c1e-4d0b-9a63-2f3a1c8d9e4b",
"status": "running",
"progress": { "fraction": 0.8 }
}
}A cancelled run settles with its cancellation block (§8.3); here a
between-steps command stopped at a boundary:
<!-- example: command/status/result -->
{
"jsonrpc": "2.0",
"id": 5,
"result": {
"command_id": "5f0c2f0a-7c1e-4d0b-9a63-2f3a1c8d9e4b",
"status": "canceled",
"cancellation": {
"requested_at": "2026-07-28T10:15:02.114Z",
"outcome": "halted_at_boundary",
"boundary": { "completed_steps": 1, "of_steps": 2, "last": "aspirate" },
"detail": "in-flight aspirate finished; dispense was never issued"
}
}
}And one whose backend never confirmed the stop; unconfirmed is the
honest settlement, not an error:
<!-- example: command/status/result -->
{
"jsonrpc": "2.0",
"id": 5,
"result": {
"command_id": "9a1b2c3d-4e5f-4a60-b7c8-d9e0f1a2b3c4",
"status": "canceled",
"cancellation": {
"requested_at": "2026-07-28T10:16:40.020Z",
"outcome": "unconfirmed",
"detail": "device stop() returned but the status object never resolved"
}
}
}<!-- example: command/cancel/request -->
{
"jsonrpc": "2.0",
"id": 6,
"method": "command/cancel",
"params": { "command_id": "5f0c2f0a-7c1e-4d0b-9a63-2f3a1c8d9e4b" }
}<!-- example: command/cancel/result -->
{
"jsonrpc": "2.0",
"id": 6,
"result": {
"command_id": "5f0c2f0a-7c1e-4d0b-9a63-2f3a1c8d9e4b",
"status": "canceling"
}
}Cancel against a running command that declares cancel_semantics: "none" is refused, never accepted-and-ignored (§8.3):
<!-- example: error/response -->
{
"jsonrpc": "2.0",
"id": 6,
"error": {
"code": -32007,
"message": "aspirate is running and declares cancel_semantics 'none': the operation is already committed to the device",
"data": {
"category": "not_cancelable",
"retryable": false,
"details": { "cancel_semantics": "none", "state": "running" }
}
}
}<!-- example: telemetry/subscribe/request -->
{
"jsonrpc": "2.0",
"id": 7,
"method": "telemetry/subscribe",
"params": { "channels": ["flow_rate"], "max_rate_hz": 5.0 }
}<!-- example: telemetry/subscribe/result -->
{
"jsonrpc": "2.0",
"id": 7,
"result": { "subscription_id": "sub-1" }
}<!-- example: telemetry/unsubscribe/request -->
{
"jsonrpc": "2.0",
"id": 8,
"method": "telemetry/unsubscribe",
"params": { "subscription_id": "sub-1" }
}<!-- example: telemetry/unsubscribe/result -->
{ "jsonrpc": "2.0", "id": 8, "result": {} }<!-- example: notifications/telemetry/notification -->
{
"jsonrpc": "2.0",
"method": "notifications/telemetry",
"params": {
"subscription_id": "sub-1",
"channel": "flow_rate",
"seq": 1042,
"timestamp": "2026-07-23T15:30:00.123456Z",
"value": 999.7
}
}<!-- example: notifications/event/notification -->
{
"jsonrpc": "2.0",
"method": "notifications/event",
"params": {
"name": "interlock/tripped",
"timestamp": "2026-07-23T15:30:05.000001Z",
"severity": "alarm",
"data": { "interlock": "over_pressure" }
}
}A resource change rides the same channel under its reserved name (§10.3):
<!-- example: notifications/event/notification -->
{
"jsonrpc": "2.0",
"method": "notifications/event",
"params": {
"name": "resource/changed",
"timestamp": "2026-07-27T09:14:07.884120Z",
"severity": "info",
"data": { "uri": "labwire:deck", "revision": "9f3c1a4e-142" }
}
}<!-- example: error/response -->
{
"jsonrpc": "2.0",
"id": 9,
"error": {
"code": -32002,
"message": "Instrument is busy: 1 of 1 command slots in use",
"data": { "category": "busy", "retryable": true }
}
}A failed reference resolution names the failure precisely and hands the agent the read that recovers (§10.4):
<!-- example: error/response -->
{
"jsonrpc": "2.0",
"id": 12,
"error": {
"code": -32010,
"message": "parameter /targets/1: 'labwire:deck/source_plate/A13' is not a container on this instrument",
"data": {
"category": "unknown_reference",
"retryable": false,
"details": {
"pointer": "/targets/1",
"parameter": "targets",
"reference": "labwire:deck/source_plate/A13",
"expected_kind": "container",
"enumerated_by": "labwire:deck",
"resolved_prefix": "labwire:deck/source_plate",
"resolved_kinds": ["plate", "labware"],
"reason": "no_such_item",
"did_you_mean": ["labwire:deck/source_plate/A1", "labwire:deck/source_plate/B1"],
"read": { "method": "resource/read", "params": { "uri": "labwire:deck" } }
}
}
}
}A refused S3 submission records a pending request and tells the agent,
in a typed field, that it cannot mint what is missing (§8.6):
<!-- example: error/response -->
{
"jsonrpc": "2.0",
"id": 19,
"error": {
"code": -32011,
"message": "move_plate is S3 and requires an operator grant bound to these exact parameters; a confirmation string cannot authorize it",
"data": {
"category": "authorization_required",
"retryable": false,
"details": {
"safety_class": "S3",
"command": "move_plate",
"reason": "absent",
"request_id": "req-3f1c8d9e",
"params_digest": "sha256:1c8d4fbb2e7a0f5d9c3b81a6e04f2d7c5b9e13a80f6c24d7e9b1a3c5f7d0e2b4",
"digest_alg": "sha256",
"canonicalization": "RFC8785",
"mintable_by_agent": false,
"operator_instruction": "On the instrument host run: labwire grant list, then labwire grant approve req-3f1c8d9e --ttl 15m --uses 1"
}
}
}
}A stale plan is refused before any confirmation or grant is spent (§10.5):
<!-- example: error/response -->
{
"jsonrpc": "2.0",
"id": 21,
"error": {
"code": -32012,
"message": "labwire:deck has moved since this plan was made",
"data": {
"category": "stale_revision",
"retryable": false,
"details": {
"uri": "labwire:deck",
"submitted_revision": "9f3c1a4e-131",
"current_revision": "9f3c1a4e-142",
"read": { "method": "resource/read", "params": { "uri": "labwire:deck" } }
}
}
}
}The manifest document example appears in §13.1. The signed bundle adds the
signature field:
<!-- example: manifest/signature-excerpt -->
{
"manifest_version": "0.2",
"signature": "hcuNZWFGkEHDDTM1XZAs2Cj1YtqBhIWU93MOWkiPYbnhr1DAOFTZaKKCyBsnrLTogVCLYzp9nsdgnG5xqRDZBQ"
}(All other manifest fields as §13.1; abbreviated here for length. The
signature value is illustrative, not a real signature over this example.)
Labwire borrows deliberately from prior art, with gratitude:
- Model Context Protocol (MCP): the initialize/initialized handshake, capability negotiation, slash-namespaced methods, newline-delimited stdio framing, and the no-batching stance.
- SiLA 2: the observable-command pattern, accept, then stream progress, then deliver a result, which shapes our command lifecycle, and the separation of commands from observable properties (our channels).
- Bluesky / Ophyd: the event-document mindset: timestamped, sequenced measurement documents, and the run-as-record idea that becomes our signed manifest.
- OPC-UA LADS: vocabulary for lab-device state machines and interlocks.
- LAP (arXiv:2606.03755): the mandatory-UCUM discipline for every quantity (§7.2, §7.3), the S0-S3 safety-class taxonomy with confirmation for costly and hazardous actions (§8.6), and the binding of an operator authorization to a capability and to a digest of its canonical parameters (§8.6). LAP binds a JWS operator token; v0.3 keeps the binding and defers the signature. Labwire and LAP are independent, convergent designs; these ideas are adopted from LAP with thanks, and no compatibility or endorsement is claimed.
- W3C Web of Things Thing Description: the placement of semantics
inside an interaction affordance's data schema rather than in a side
table, which decided
resource_refand the content-schemaunitkeyword (§7.2, §7.6), and theunitterm itself. - JSON-LD: the intuition that a value can be a typed link to a named
node rather than a literal. Labwire v0.3 is not JSON-LD: there is no
@context,labwire:URIs are not IRIs into a shared vocabulary, andkindis matched within one instrument against Appendix A alone. The intuition is borrowed; the machinery is deliberately not. - MCP resources: the resource primitive itself, reduced to one read method and in-descriptor declaration (§10).
- HTTP (RFC 9110): conditional-request thinking behind
revision,if_revision, and terminal-status revision reporting (§10.3, §10.5). - RFC 3986 / RFC 6901 / RFC 8785: the URI shape, the error pointer form, and the canonicalization under every digest.
A detailed, honest comparison, including what these systems do better than
Labwire, lives in PRIOR_ART.md at the repository root.
- 0.4.0 (2026-07-28): Protocol version
"0.4". Cancellation made honest, prompted by field reports from an Opentrons Flex owner and the PyLabRobot maintainer (SPEC-FINDINGS F10). Added: per-commandcancel_semantics("abort","between_steps","none"; §7), acknowledgment-vs-settlement in §8.3, and thecancellationsettlement block on terminal CommandStatus and in signed manifests (§13.1), including the first-class"unconfirmed"outcome for stops the backend cannot vouch for. Removed (breaking): theinterruptibleboolean; its cancellable-by-default semantics are the behavior the field reports indicted. Undeclared commands now default to"none". Manifest version"0.4"; 0.3 and earlier bundles still verify. - 0.3.0 (2026-07-27): Protocol version
"0.3". Things, not only quantities. Added: resources: URI-identified, typed, readable instrument state declared in the descriptor (§7.6) and read withresource/read(§10), with derived revisions and the reservedresource/changedevent; typed references: theresource_refschema keyword, validated against current resource state at submission with the new error-32010(unknown_reference); operator grants forS3: out-of-band provisioned, bound to a command and the RFC 8785 digest of its normalized parameters (a binding adopted from LAP with credit), expiring and use-limited, refused with the new error-32011(authorization_required); optimistic concurrency:if_revisionon submit with the new error-32012(stale_revision), andresource_revisionson terminal status. Breaking:InstrumentDescriptor.resourcesis REQUIRED; aconfirmationno longer satisfiesS3; submission precedence movesinterlockand capacity ahead of confirmation and authorization (§12.1); the errordatarequirement extends to-32012; manifests are"0.3"withcommand.paramsnow the normalized parameters (in v0.2 a command with defaulted optionals signed a manifest describing something other than what ran), plusparams_digest,authorizationwith a REQUIREDidentity_verified: false, andresource_revisions; theunitandresource_refschema keywords are claimed,unitREQUIRED on numeric nodes incontent_schemaand forbidden in command schemas. Verifiers accept both"0.2"and"0.3"bundles. - 0.2.1 (2026-07-27): Corrective. The unit rule in §7.2 said "every
numeric parameter (JSON Schema type
numberorinteger)", which a reference implementation read literally, so an array of numbers carried no obligation and the guarantee that no quantity crosses the wire without a unit was false for every command that passes quantities as arrays. The rule now covers a parameter that carries a number anywhere in a conforming instance, and an object parameter with numeric fields, which the scheme cannot annotate, MUST be rejected rather than served. Protocol version stays"0.2": no message shape changed, and a v0.2 descriptor that was correct under the old wording is still correct unless it was relying on the hole. - 0.2.0 (2026-07-26): Protocol version
"0.2". Breaking:unit_annotationsandreturns_unitsare now REQUIRED on every command and MUST carry a UCUM code for every numeric parameter and numeric result field (dimensionless ="1");ChannelSpec.unitMUST be a UCUM code. Added: per-commandsafety_class(S0-S3, defaultS1, §8.6) with mandatoryconfirmationonS2/S3submissions and the new error-32009(confirmation_required); optionalqudt_quantity_kinddeclarations;command.safety_classinside signed manifests (§13.1). Units and the safety taxonomy are adopted from LAP with credit (§17). - 0.1.0 (2026-07-23): Initial draft. Protocol version
"0.1".
kind names without a dot are reserved for this registry; anything else
MUST take the form <vendor>.<name>, and clients MUST treat unrecognized
vendor kinds as opaque. This registry works the way UCUM codes do: an
instrument looks a name up, it does not invent one, because a kind two
instruments spell differently is the fragmentation typed references exist
to end.
Stated honestly: this registry is seeded from the single domain that forced the feature (liquid handling) and is maintained by this project alone. It is expected to grow one proven need at a time, and a governance process is future work recorded in ROADMAP.md.
| Kind | Meaning |
|---|---|
deck |
The working area of a liquid handler |
labware |
Anything an instrument can hold or move |
plate |
A multi-well plate (also labware) |
tip_rack |
A rack of pipette tips (also labware) |
trough |
A single-cavity reservoir (also container, labware) |
trash |
A disposal target (also labware) |
lid |
A plate lid (also labware) |
container |
Holds liquid: a well, a tube, a trough cavity |
tip_site |
One spot of a tip rack |
site |
A position labware can stand on |
consumable |
An installed consumable: a syringe, a cartridge |
An index entry lists every kind it satisfies (§10.2), most specific
first, so a trough entry reads ["trough", "container", "labware"] and a
reference declaring any of the three resolves to it. There is no subtyping
graph in the protocol; the instrument declares the set.
- [JSONRPC] JSON-RPC 2.0 Specification, https://www.jsonrpc.org/specification
- [RFC 2119] Key words for use in RFCs to Indicate Requirement Levels
- [RFC 8174] Ambiguity of Uppercase vs Lowercase in RFC 2119 Key Words
- [RFC 3339] Date and Time on the Internet: Timestamps
- [RFC 8032] Edwards-Curve Digital Signature Algorithm (EdDSA)
- [RFC 8785] JSON Canonicalization Scheme (JCS)
- UCUM: The Unified Code for Units of Measure, https://ucum.org
- JSON Schema (draft 2020-12), https://json-schema.org