Plugin developer SDK
The SDK gives you a small starting point to develop a BenchWeave device plugin with AI: generate, implement, test, review, then prepare a release. Use this guide for SDK installation, project generation, testing and packaging. The device developer guide (main repository) covers device evidence, integration requirements, firmware and qualification.
The device is the hardware. The plugin is its Python integration and descriptor. Follow the developer directory convention: plugins/<manufacturer>/<name>/, with src/<python_package>/ inside that project. The name normally identifies the device model, such as fnirsi/dps150.
The current SDK is benchweave-sdk 0.15.0 for Python 3.13+, OTDP 0.2.3 and adapter API 1.1, with registry 0.1.3 schemas. Its source lives in this repository. The main BenchWeave repository mounts it at packages/sdk. Build it separately from the gateway. It supplies structural typing interfaces, offline contract validation, a scripted mock host, bounded async conformance helpers, an external project generator and file-inventory helpers.
1. Install the SDK and generate your project
Install the SDK from PyPI:
# plugin authoring: `new` renders the copier template (issue #347 WS2)
pip install 'benchweave-sdk[scaffold]'
# scaffold-free install (check/conformance/preview only)
pip install benchweave-sdk
# or as an isolated CLI tool
uv tool install benchweave-sdkFor the newest unreleased code, install from the default branch:
pip install git+https://github.com/madeinoz67/benchweave-sdk.gitFull channel list: see the README Installation section.
PyPI is the default install channel: pip install benchweave-sdk or uv pip install benchweave-sdk. Contributors and maintainers can instead build from a checkout of the main BenchWeave repository. Run this from that checkout’s root. packages/sdk is the submodule mount path there, not a directory in this repository:
uv build packages/sdk --out-dir dist/sdkIn your development directory, create an environment. Then install the built wheel from the checkout’s dist/sdk/:
uv venv --python 3.13
source .venv/bin/activate
uv pip install 'dist/sdk/benchweave_sdk-*.whl[scaffold]'
benchweave-sdk new plugins/acme/model100 --package benchweave_acme_model100
cd plugins/acme/model100
uv pip install -e '.[test]'
pytestKeep the environment that contains the SDK wheel. If you create a new environment inside the project, install the wheel there too. For a repeatable uv lock against a locally built wheel, configure that wheel or your organisation’s package source explicitly.
Run benchweave-sdk doctor from the project to compare the pin with the installed SDK. The command names the recorded version, the installed version, and the reinstall command when they differ. The command works offline and does not fail the run. A stale SDK is a report, not an error.
The generated project also carries .copier-answers.yml at its root: the durable provenance record of which SDK template version produced it (new pins the canonical repository and the matching v<version> template tag — template version == SDK version). The pyproject pin that doctor reads remains a mutable declaration the author may move; the answers file is the record.
Moving a project to a newer template: benchweave-sdk upgrade (from the project root, on a committed and clean git tree) fetches the installed SDK’s released template tag from the canonical repository and applies it — author-owned files (pyproject, README, CLAUDE.md, src/<pkg>/**, tests/**) are preserved byte-for-byte; the managed AI-GUIDE.md moves with the template, and a file you both changed gets conflict markers to resolve and commit. Released template tags only: a development install’s version has no tag and the update refuses with upgrade_tag_missing. Projects scaffolded before the copier port gain provenance with benchweave-sdk adopt — it infers the package name and base version from the generated pyproject.toml (--package / --scaffolded-at override) and refuses a project whose provenance it cannot establish.
Replace acme/model100 with your manufacturer and device or plugin name. The destination controls the directory hierarchy. The --package option controls the Python import package. The SDK creates missing parent directories and refuses to overwrite an existing project. A destination that you reach through a symlinked directory is canonicalized before any write. On macOS, /tmp/... is such a symlink. Messages report the real path.
plugins/
└── acme/
└── model100/ # Independent build/test/release root
├── pyproject.toml
├── README.md
├── AI-GUIDE.md
├── CLAUDE.md # Agent notes; root dev tooling
├── AGENTS.md # SDK-managed agent guide; CLAUDE.md imports it
├── .claude/skills/ # Five SDK-managed workflow skills
├── src/benchweave_acme_model100/
│ ├── __init__.py
│ ├── adapter.py
│ ├── protocol.py
│ ├── descriptor.json
│ ├── protocol.md
│ ├── vectors.json
│ └── skills/ # Seeded agent skills; ship in the wheel
│ ├── develop-plugin/SKILL.md
│ └── drive-device/SKILL.md
└── tests/test_plugin.py
The root also carries AGENTS.md, the SDK-managed agent guide: CLAUDE.md imports it, and benchweave-sdk upgrade refreshes it together with the five workflow skills under .claude/skills/ (benchweave-plugin-workflow, benchweave-descriptor, benchweave-plugin-ui, benchweave-adapter-testing, benchweave-capture). They are repo-level dev tooling outside src/<python_package>/; they never enter the plugin wheel. The seeded package skills inside src/<python_package>/skills/ are different content and do ship in the wheel. If you edit a managed file, the upgrade writes conflict markers; resolve both sides, then commit. A project made before these files existed adds the @AGENTS.md import line to its CLAUDE.md by hand.
Generate the dependency lock and retain it. Add your chosen licence file. When you develop custom firmware, add firmware/ with its own build and tests. The starter does not create these project-specific files. You can copy the manufacturer and name directory into its own external repository unchanged. It has no runtime dependency on BenchWeave core or the SDK. The SDK is a development dependency.
The starter deliberately implements a made-up, read-only identify and voltage protocol. A pass of its tests demonstrates that the tools work. It says nothing about your instrument.
2. Give the AI the facts and prompts
Open the generated AI-GUIDE.md. It contains five copyable prompts: establish device facts, implement with mocks, demonstrate behaviour, prepare independent review, and prepare release artefacts for owner review.
Provide the exact model, firmware, transport, supported operations and authoritative protocol evidence. Review the AI’s proposed scope before you replace the example. The AI should keep the protocol code separate from the async adapter. The AI should use only the supplied scoped host services. The current gateway loader supports package-relative and standard-library imports. Arbitrary third-party runtime dependencies need more integration work.
For custom hardware, follow Build my own device firmware (main repository) as well. Firmware and the Python plugin have separate builds and compatibility evidence, even when you maintain them in one project.
3. Run the checks
From your plugin project, use the environment that contains the SDK:
pytest
benchweave-sdk check src/benchweave_acme_model100/descriptor.json
uv buildGenerated tests cover identify and read, quiet lifecycle, invalid inputs, pre-dispatch cancellation and expiry, and uncertain malformed and transport responses. Extend them for your actual operations, failure modes and evidence. check_operation validates request and result schemas and correlation. check_lifecycle checks a quiet lifecycle and repeated cleanup. Both bound cooperative async calls with a configurable wall-clock timeout. Python that blocks, or code that suppresses cancellation, needs process-level isolation.
Descriptor checks currently cover its schema, matching capabilities and policies, unique parameter names, ordered ranges and transport-provider declarations (next subsection). These are partial checks, not all S01–S18, C01–C12 or M01–M14 obligations. The SDK provides no physical transport provider, complete profile or capture test harness, or hardware qualification certificate.
Standards versions: per-pin validation and the served set
The SDK does not validate against “the” OTDP schema. It vendors a multi-version standards tree. It validates every document against its descriptor’s own pinned version. The otdp_version field decides which bytes answer. A 0.2.0-pinned descriptor validates against 0.2.0’s schemas. A 0.2.2-pinned descriptor validates against 0.2.2’s schemas. Neither is reinterpreted through the active version. Explicit otdp/<version>/<file> lookup keys resolve the same way.
standards-lock.json pins what the SDK carries: one row per retained, in-range (id, version), with yanked-in-interval versions carried but marked. The lock also carries a verbatim mirror of the gateway’s dependency_policy block. The mirror holds the declared ranges, yank records and retirements. Every vendored file is sha256-checked against its lock row at load. benchweave-sdk sync-standards --check re-verifies the committed tree, the stamps and the row-marker and mirror agreement offline, with no gateway checkout.
Pin classification follows that state, under stable refusal prefixes:
version_not_served:means that this SDK does not carry the pin. The pin is out of the declared range or was never retained here. The refusal carries the five VR-37 fields: standard, pinned version, supported range, nearest move-to and the migration-note pointer.retired_identifier:means that the pin names a retired version. A retired version was used, is now dead, and will never be reissued.- A yanked pin is carried and yank-marked. It stays conforming and validates against its own bytes. A deprecation warning names the derived move-to. The move-to is the highest served version. When nothing is served, the declared range’s lower bound is the guidance. When the pin orders above every served version, the move-to is a downgrade. The warning then carries the exact text
(a downgrade — no served version is newer). It does not present the move-to as an upgrade path.
Transport providers (custom transports)
A custom transport may declare a pinned transport-provider contract (OTDP 0.2.1). The declaration is an optional provider object on the transport. It carries the required feature_id and an {id, path, sha256} pin to the reviewed contract document inside your plugin package. benchweave-sdk check verifies the whole declaration offline. It refuses with named, machine-matchable prefixes:
provider_feature_missing:means that the provider’sfeature_idis not inrequired_features.provider_transport_undeclared:means that anotdp.transport.*feature is required but no provider on the custom transport declares it. It also fires when the provider’sfeature_idis not a sanctionedotdp.transport.<name>/<semver>id. Such a declaration is one that nothing admissible can ever back.unknown_otdp_feature:means anotdp.*feature id that is neither corpus-known nor declared through a provider. The namespace is corpus-owned. A typo or a fake version does not pass.provider_contract_missing:means that the pin does not resolve to a contained regular file. Provider pins are descriptor-relative: the contract ships beside your descriptor, and subdirectories are allowed. Provider pins refuse traversal, absolute paths and symlinked components.provider_contract_hash_mismatch:means that the pinned bytes do not hash to the pinnedsha256.provider_contract_invalid:means that the pinned document fails one of these checks: the transport-provider contract schema; grammar subschemas that are not valid Draft 2020-12 schemas; kinds that are not unique strings; a kind that shadows a generic transfer kind; identity fields that disagree, including disagreement with the descriptor’s own declaration.
A provider in the wrong place refuses through the ordinary error surface for schema validation. The wrong places are a non-custom transport and the missing adapter integration mode. Provider-object shape faults refuse through the same surface. A custom transport without a provider stays valid. That is the honestly-incomplete posture, not a refusal. The Python entry points behind these checks are validation.validate_transport_provider (contract documents) and validation.verify_provider_pin (pin resolution).
These checks prove the declaration well-formed and self-consistent only. Only the gateway can prove the grant: that your host admitted the pinned contract and that connection_key resolves to a connection it backs. An offline pass is not an admission claim.
Local UI preview (simulated)
benchweave-sdk check-ui and check-preset validate presentation candidates offline. preview-ui previews a plugin project. It serves the standalone host on the scripted mock transport, on loopback only. The command needs the [server] extra. On a default installation, it refuses with benchweave_sdk_server_extras_missing: and exit 2. preview-ui never leaves the mock transport. Every scenario, observation and control receipt is simulated and labelled as such. The preview path has no gateway, registry or hardware reach. The served document’s shape is pinned by plugin-ui-preview-v1 (main repository). The emitter validates it. The renderer_version field of the served document reports the version of the SDK that emitted it. Previews render the manifest’s declared plots and channel hints from per-scenario snapshot values. Each observed target gets one simulated value. Previews do not show observation history.
For real hardware, use benchweave-sdk-server serve --transport serial directly. The command starts without a device path. Scan from the index page, then bind one listed port with its Bind button. Bind and Unbind need the operator action token. serve prints this token at startup. The launch URL carries it. The command also writes it to operator-action-token.json under the capture root. The binding is stored in device-bindings.json under the working directory. Name another file with --bindings <path> or BENCHWEAVE_STANDALONE_BINDINGS. The stored binding survives a restart: the next serve connects to the bound port without a device path. A connect refuses not_ready with reason binding_stale when the bound port is gone. The device page shows a re-pick action beside this refusal. A bind also refuses when the picked port is no longer present, or when the port now carries a different USB serial than the scan recorded. Scan again, then pick one of the listed ports. A connect also refuses when the unit answers the identify request with a different manufacturer or model than the descriptor declares. The session stays closed. Pass --device <path> for the headless form. The host then serves that constant path. The bindings file is still opened and validated at startup. The stored binding is not consulted to resolve the endpoint in this form. The pick flow in the UI can still write the file.
The SDK’s runtime dependencies are Click, jsonschema (with the referencing, rfc3339-validator and rfc3987 format backends) and Rich. This repository’s pyproject.toml declares them all. In a main-checkout layout, the file is at packages/sdk/pyproject.toml. The wheel vendors the canonical contract JSONs and the pure presentation validator.
Operational notes: preview-ui is an interactive tool. It serves until you interrupt it, by design. It exits 0 on SIGINT as a normal stop. The default port is 8477. Pass --port to change it.
Under the explicitly acknowledged --allow-network mode, request bodies have no handler timeout. Only untrusted peers are affected. Do not expose the preview to untrusted peers.
The plugin stylesheet override (preview.css)
A plugin can declare a local stylesheet override for its preview pages. The manifest declares an assets[] row whose path is exactly preview.css. The row also declares the file’s sha256.
The manifest row. Add an assets[] row to the manifest. Give path the value preview.css. The value must be exactly preview.css. Give sha256 the digest of the file bytes. Put the file in the plugin’s resource directory.
{
"assets": [
{ "path": "preview.css", "sha256": "5f2a9c8e1b7d4f6a0c3e9d2b8a7f1e4c6d5b3a9f0e8c7d6b5a4f3e2d1c0b9a87" }
]
}The serve-time checks. At serve time the host checks the file bytes against the row’s sha256. The file can be 64 KiB at most. The host serves it as text/css. The override loads last on your plugin’s preview pages. The gateway never serves a plugin stylesheet override.
The refusal. If the bytes do not match the pin, the host refuses the override. The page renders with the default tokens and carries the marker attribute data-bw-override-refused on the html element. A direct fetch of the file returns 403 with the prefix standalone_preview_override_refused:. A fetch when the manifest declares no override returns 404. The host also refuses an unreadable file or a file above the cap.
The cascade. The override wins at equal CSS specificity, because it loads last. The host rules for the lanes figure sit at specificity (0,2,0). An example selector is .bw-lanes__segment[data-bw-state-kind="high"]. To change such a rule, an override must copy the exact selector. The reduced-motion rules are an accessibility floor. They carry !important, and an override cannot reach them.
The theme. The preview uses the system color scheme, and it renders light and dark. The behavior is the same as the gateway console.
The capture page. A page that carries a digital_lanes plot leads with the lane traces. The reading tiles sit below as the secondary surface. A page without a lanes plot keeps the tile-first layout.
The lanes figure today. The figure shows the declared structure only. The acquisition line shows Acquired 0 samples · plotted 0. A channel hint visible: false hides that lane in the preview. The preview fixtures carry no capture data yet, so the viewport toolbar does not appear today. A later release will add capture data to the preview fixtures, and the toolbar will then appear. The paragraphs below describe these controls.
The viewport toolbar. A lanes figure over a capture with samples carries a viewport toolbar below it. The toolbar holds the controls ◀, Full, Zoom ×½ and ▶. The server computes the window for each control. The ◀ and ▶ controls move the shown window back or forward by half its size. The Zoom ×½ control shows the middle half of the shown window. The Full control shows the whole capture again.
The rendered controls. The ◀, ▶ and Zoom ×½ controls do not render when their action has no effect. The Full control always renders. A capture without samples renders no toolbar at all.
The window swap. Each toolbar control asks the server for a window of the capture. The request carries two parameters, from and to. Both parameters are absolute sample indices, and sample 0 is the first sample of the capture. The server renders the same lanes against the requested window. The browser then replaces the whole viewport block, figure and toolbar, with the served block. The page refreshes its readings on its own, and a refresh never touches the lanes region.
The served window. The server clamps a requested window into the capture. A request before sample 0 clamps to sample 0, and a request beyond the last sample clamps to the last sample. The served block carries the served window in data-bw-window, which names the first and the last shown sample. The axis label names the served sample span, for example samples 500–999. The acquisition line reports the counts of the shown window. The block also carries the marker data-bw-window-clamped when the served window differs from the request.
The viewport refusal. The server refuses a window request that it cannot serve. The refusal returns status 400, and its body starts with viewport_window_refused:. An empty window or an inverted window refuses. This can happen when the capture changed after the server built the toolbar. The browser then shows a notice in the viewport region. The notice tells the operator to push Full, and the Full control always serves.
Mock transports (offline rehearsal)
The standalone host serves a plugin on a scripted mock transport when you run serve --transport mock or preview-ui. For serve, the mock transport is the default. The script comes from the evidence file of the plugin, src/<package>/vectors.json. The file declares which of the two transaction dialects the adapter uses. The host serves the mock that matches the dialect.
The two dialects. Declare the dialect with the file-level transaction_dialect key. If you omit the key, the file is a stream_exchange script. Every row is a {"request": ..., "response": ...} pair of strings. The mock serves whole-dict exchanges. This is the shape of the starter protocol. The request is ID?\n and the response is one line.
If you declare "transaction_dialect": "send_receive", the rows form a binary section 8.1 SEND/RECEIVE conversation. The strings of each row are frames. The stream_send of the adapter must match the next scripted request frame exactly. The mock serves stream_receive with the exact outcomes of the serial backend. The section “A standalone runtime around the writer” below states these outcomes.
Encoding. By default, the bytes of a row are the ASCII bytes of the row string. Set "encoding": "hex" at file level, or on one row, for binary frames. A string is then a hex byte string, for example "response": "53544154000a". The ascii encoding refuses a string that is not ASCII. The hex encoding refuses an odd-length string or a non-hex string. Both refusals give the row.
The row contract. Every row carries a response. A row without request is a response-only row. A response-only row is a device-initiated frame that the mock releases to the inbound stream. The release happens in row order, when the conversation reaches the row. Response-only rows need transaction_dialect: "send_receive". The default dialect cannot contain an unsolicited frame.
A row can also carry a name. Diagnostics refer to a row by its name or by its index.
How selection works. The host reads the declaration at connect time and builds the matching mock host. Nothing else changes. serve --transport serial never reads vectors.json. The script is rehearsal evidence, not device configuration.
How the script loops. The mock detects a repeating unit from the captured rows. The rows before the first full unit are the establishment. The mock serves these rows once. The unit then serves every later cycle in captured order.
A capture needs at least two full units of evidence. A capture with no derivable unit is not an error. Two causes are usual. A truncated capture is one cause. A polled value that varies across occurrences of the same request is another cause. Such a capture then obeys the plain tail rule. Under this rule, row 0 is the establishment. The whole tail becomes the cycle. The cycle repeats in captured order on every restore.
A repeating unit must contain at least one row with a request. A response-only row releases once per cycle pass. A captured burst drains once. Then the line goes quiet. When the adapter consumes a row with a request, the cycle re-arms.
What the mock can and cannot rehearse. The send_receive mock reproduces exactly the RECEIVE outcomes of the serial backend: exact_bytes precedence over termination, the lf and crlf terminators, the overlong-line discard, the quiet-line {"data": b""}, and a partial frame retained across a deadline. The mock releases unsolicited frames strictly in row order. The mock cannot rehearse the interleaving that a real streaming device produces between rows. The mock does not serve can_receive, can_send, i2c_transfer or spi_transfer. A plugin that needs those kinds needs a real transport.
The timings of the mock are not the timings of the device. The quiet line answers at once, not after the quiet window of the backend. A receive that cannot complete raises the TimeoutError of the backend at once. The mock does not wait until the deadline.
For this reason, a rehearsal proves the command flow, not the timing budget. A capture that fits its timeout_ms on the mock proves nothing about the hardware budget. Budget-fit evidence needs --transport serial and the device.
The bound, in simple words. The discipline of the mock is exact. When a frame that the adapter SENDs differs from the frame of the scripted row, the rehearsal fails. The failure gives the row and both frames. The mock never guesses what the device meant.
But a script can prove only what its rows record. A script that does not match the real device proves nothing about the device. Use --transport serial and the hardware for interleaving, for non-serial transports, for timing, and for any claim about the device.
Capture without a gateway (standalone writer)
Plugin and bench development happens on machines that never run the gateway. benchweave_sdk.capture.StandaloneCaptureWriter implements the three capture methods that an adapter calls in gateway mode: artifact_append, artifact_finalise and artifact_abort. The writer works over one directory per capture on a local filesystem. It is three of the eight CaptureServices members. Device I/O still comes from a transport that a composing runtime supplies.
from benchweave_sdk.capture import StandaloneCaptureWriter
writer = StandaloneCaptureWriter() # or StandaloneCaptureWriter(some_path)
await writer.artifact_append("cap-demo-1", chunk, context)
manifest = await writer.artifact_finalise("cap-demo-1", {
"format": "waveform_f64le",
"started_at": "2026-09-22T00:00:00Z",
"sample_count": 1024, "sample_interval_s": 0.001, "unit": "V",
}, context)The capture root is an explicit constructor argument. Next comes the BENCHWEAVE_CAPTURE_DIR environment variable. Last comes captures/ under the current working directory. A relative root resolves against the working directory when you construct the writer.
The writer refuses a root inside the installed package tree. A reinstall or upgrade wipes such a root, and the SDK’s inventory verification refuses unlisted files. The writer also refuses a BENCHWEAVE_CAPTURE_DIR that is set but empty or whitespace-only. It also refuses a root that exists but is not a directory.
A reinstall or an upgrade deletes a capture root inside the installed package tree. Do not put the capture root there.
Each capture event is one directory. The directory holds manifest.json, a staging/ tree while chunks accumulate, the primary artefact, and an optional renderings/ tree that the plugin writes itself. The primary artefact name depends on the format: <capture_id>.f64 for waveform_f64le, .bin for raw_binary, .csv, .txt or .vcd for declared extension formats, and .data otherwise.
The manifest’s sha256, byte_length and artifact_id (art-<sha256>) are computed over the real published bytes. Standalone extras live under x-standalone-* keys. The finalise format must be in the constructor’s declared set. The set comes from formats=, and it holds the two core formats by default. An empty set refuses everything. The writer refuses a capture that appended nothing. It does not publish an empty capture. It also refuses a waveform_f64le capture whose byte length is not sample_count × 8, as the gateway refuses that too. An abort deletes only staging/ and the in-flight primary. A published capture and renderings/ survive an unconditional finally-cleanup abort.
Standalone does not apply the gateway’s two host-side admission checks: the descriptor’s capture_limits (G2) and the host’s storage quota (G3). A capture that finalises standalone can still be refused by a gateway.
There is no automatic import of standalone captures into the gateway. Ingest is a separate, deliberate path.
A standalone runtime around the writer (worked example)
The writer is three of the eight members. A bench script that drives an adapter without a gateway needs the other five as well: clocks, evidence, and the transport. The example below is one complete CaptureServices object for a device on a serial port. A contributor fork of the ADC board wrote a serial host for the same purpose, and this example is adapted from it.
It implements the three scoped stream transactions of OTDP §8.1 exactly. It adds one behaviour that the spec leaves unspecified: the quiet-line answer below. The transactions are stream_send, stream_receive and stream_exchange. In them, exact_bytes takes precedence over termination. Only lf and crlf terminators exist here, because serial has no eom. Unspecified fields are refused. An incomplete frame is never returned as complete.
A receive that sees no bytes at all for a short quiet window answers {"data": b""}. Nothing was offered. Adapters read that as a quiet line, not as an error. The DPS-150 plugin’s telemetry drain ends on exactly that answer. Once a frame starts, it is only ever returned whole. Bytes already read when a deadline expires stay buffered for the next receive, so a slow reply is not lost.
The capture methods delegate to StandaloneCaptureWriter. This is author-side tooling, not an SDK module. Copy it into your project and adapt it.
import asyncio
import json
import time
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from benchweave_sdk.capture import StandaloneCaptureWriter
_TERMINATORS = {"lf": b"\n", "crlf": b"\r\n"}
_RECEIVE = {"max_bytes", "termination", "exact_bytes"}
_FIELDS = {
"stream_send": {"kind", "data"},
"stream_receive": {"kind"} | _RECEIVE,
"stream_exchange": {"kind", "data"} | _RECEIVE,
}
class BenchContext:
"""A minimal OperationContext for bench use: identity, deadline, cancellation."""
def __init__(self, operation_id: str, timeout_s: float,
dataset_id: str | None = None) -> None:
self.operation_id = operation_id
self.dataset_id = dataset_id
self.deadline_monotonic = time.monotonic() + timeout_s
self.dispatched = False
self._cancelled = False
def cancel(self) -> None:
self._cancelled = True
def is_cancelled(self) -> bool:
return self._cancelled
async def mark_dispatch_started(self) -> None:
self.dispatched = True
class SerialStandaloneHost:
"""All eight CaptureServices members over one serial port, without a gateway.
``port`` is anything with pyserial's ``write(bytes)``, ``read(size) -> bytes``
(returning early on the port's own short timeout, which bounds how quickly a
deadline or cancellation is noticed) and ``close()``. ``quiet_s`` is how long
a receive waits for its first byte before answering that nothing was offered.
"""
def __init__(self, port: Any, *, writer: Any = None, evidence_path: Path | None = None,
max_frame_bytes: int = 4096, quiet_s: float = 0.1) -> None:
self._port = port
self._writer = writer if writer is not None else StandaloneCaptureWriter()
self._evidence_path = evidence_path
self._max_frame = max_frame_bytes
self._quiet_s = quiet_s
self._pending = bytearray() # read from the port, not yet returned
self._closed = False
self.evidence: list[dict[str, Any]] = []
# clocks
def monotonic(self) -> float:
return time.monotonic()
def utc_now(self) -> str:
return datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
# transport (OTDP section 8.1 stream transactions)
def _live(self, context: Any) -> None:
if context.is_cancelled() or self.monotonic() >= context.deadline_monotonic:
raise TimeoutError("operation cancelled or expired")
if self._closed:
raise ConnectionError("serial port is closed")
async def transfer(self, transaction: dict[str, Any], context: Any) -> dict[str, Any]:
kind = transaction.get("kind")
if kind not in _FIELDS or set(transaction) != _FIELDS[kind]:
raise ValueError(f"not a section 8.1 stream transaction: {sorted(transaction)}")
receive = None
if kind != "stream_send":
receive = self._receive_bounds(transaction)
if kind != "stream_receive" and not isinstance(transaction["data"], bytes):
raise ValueError("data must be bytes")
self._live(context)
if kind != "stream_receive":
data = transaction["data"]
try:
sent = await asyncio.to_thread(self._port.write, data)
except OSError as error:
raise ConnectionError(f"serial write failed: {error}") from error
# pyserial reports a short count without raising when a pending write
# is cancelled, or under write_timeout=0. None means the port reports
# no count (pyserial's own rs485.RS485 and cp2110:// ports).
if sent is not None and sent != len(data):
raise ConnectionError(f"serial write reported {sent} of {len(data)} bytes")
if kind == "stream_send":
return {}
return {"data": await self._receive(*receive, context)}
def _receive_bounds(self, t: dict[str, Any]) -> tuple[int, bytes, int]:
max_bytes, termination, exact = t["max_bytes"], t["termination"], t["exact_bytes"]
if not isinstance(max_bytes, int) or isinstance(max_bytes, bool) \
or not 1 <= max_bytes <= self._max_frame:
raise ValueError(f"max_bytes must be 1..{self._max_frame}")
if termination not in _TERMINATORS: # serial does not support "eom"
raise ValueError("termination must be 'lf' or 'crlf' on a serial port")
if exact is not None and (not isinstance(exact, int) or isinstance(exact, bool)
or not 0 <= exact <= max_bytes):
raise ValueError("exact_bytes must be None or 0..max_bytes")
return max_bytes, _TERMINATORS[termination], exact or 0
async def _receive(self, max_bytes: int, terminator: bytes, exact: int, context: Any) -> bytes:
quiet_until = self.monotonic() + self._quiet_s
while True:
if exact: # a positive exact_bytes takes precedence over termination
if len(self._pending) >= exact:
return self._take(exact)
else:
end = self._pending[:max_bytes].find(terminator)
if end >= 0:
return self._take(end + len(terminator))
if len(self._pending) >= max_bytes:
self._take(max_bytes) # discard, so the next call can resynchronise
raise ValueError(f"no terminator within {max_bytes} bytes")
if not self._pending and self.monotonic() >= quiet_until:
return b"" # nothing offered: a quiet line, not an error
self._live(context) # a partial frame stays pending across the timeout
want = (exact or max_bytes) - len(self._pending)
try:
chunk = await asyncio.to_thread(self._port.read, max(1, want))
except OSError as error:
raise ConnectionError(f"serial read failed: {error}") from error
self._pending += chunk
def _take(self, count: int) -> bytes:
out = bytes(self._pending[:count])
del self._pending[:count]
return out
async def close_transport(self, context: Any) -> None:
if not self._closed:
self._closed = True
await asyncio.to_thread(self._port.close)
# evidence
async def record_evidence(self, entry: dict[str, Any], context: Any) -> None:
record = {**entry, "at": self.utc_now(), "operation_id": context.operation_id}
self.evidence.append(record)
if self._evidence_path is not None:
with self._evidence_path.open("a", encoding="utf-8") as stream:
stream.write(json.dumps(record, sort_keys=True) + "\n")
# capture: the SDK's standalone writer
async def artifact_append(self, capture_id: str, data: bytes, context: Any) -> None:
await self._writer.artifact_append(capture_id, data, context)
async def artifact_finalise(self, capture_id: str, metadata: dict[str, Any],
context: Any) -> dict[str, Any]:
return await self._writer.artifact_finalise(capture_id, metadata, context)
async def artifact_abort(self, capture_id: str) -> None:
await self._writer.artifact_abort(capture_id)Using it with pyserial and a plugin’s adapter:
import serial # pyserial, installed separately
port = serial.Serial("/dev/ttyUSB0", 115200, timeout=0.05)
host = SerialStandaloneHost(port, writer=StandaloneCaptureWriter(formats=("raw_binary",)))
plugin = create_plugin()
open_context = BenchContext("open", timeout_s=2.0)
await plugin.open(descriptor, host, open_context)
op_context = BenchContext("bench-1", timeout_s=2.0)
result = await plugin.execute({"operation_id": "bench-1", "verb": "read",
"arguments": {"parameter": "voltage"}}, op_context)Give each operation its own BenchContext. The adapter checks operation_id against the request. tests/test_guide_serial_host.py executes the first block above and drives a freshly scaffolded plugin through it, so the example cannot drift from the SDK or from the generated adapter.
Reconfigure the serial link at runtime
Some devices switch their line speed without a reconnect. The descriptor declares this with x-negotiated-bauds under transport.settings. The value is a list of baud rates the adapter may switch to. The boot baud is always allowed. Without the declaration, every switch refuses. The capability is opt-in by design.
The host checks the declaration when the session builds. A malformed value stops the build with a clear error. The host adds no range rules. Which baud rates a bench may use is the descriptor author’s decision.
The serial host in the SDK server serves this capability. An adapter detects it with hasattr(services, "reconfigure_link"). A host without the capability hands adapters plain HostServices. One switch request carries exactly {"baud": <int>}. Parity, stop bits, data bits and flow control stay at their boot settings. A request with any other field refuses.
A switch is one bounded attempt:
- The host refuses the request while a receive is in flight.
- The host closes the old link. The old ring dies with it. Bytes that arrived at the old baud are not frames at the new one, so the new link starts empty.
- The host reopens the port at the boot settings, with the new baud in place.
- The host returns the applied settings. This read-back is the proof.
The host never retries a failed switch. A failed reopen leaves no serving link and raises ConnectionError. The adapter may then request the boot baud as its own explicit step. That is the adapter’s move.
host_info and device_get carry a link block on the serial transport: the current baud, the boot baud, and whether any switch is negotiable. The block is null on other transports and before a connection. The event stream carries the link family. Each event says what happened (reconfigured, reconfigure_refused or reconfigure_failed), both baud rates, and the operation id. ### Capture retention and the library page (standalone host)
The standalone host adds retention, a storage reserve and a library page over one capture root. A retention rules document states what the host keeps and what it removes. The document is a JSON file with an explicit path. Its top-level keys are interval_s, orphan_grace_s, reserve_bytes and rules, and every key is optional. Each rule has a unique, non-empty id. A rule can carry the match keys source (ui, rest or mcp) and project (an exact string). An absent match key matches anything.
Each rule needs at least one limit. max_age_d removes captures strictly older than the given number of days. max_count keeps only the newest N captures. max_bytes keeps only the newest prefix that fits. Without a document, the shipped ruled defaults apply: mcp captures are kept 30 days unless pinned, and ui and rest captures are kept until you delete them. The defaults ship as a document inside the package (retention-defaults.json), not as host constants. A custom document does not replace the defaults. The document’s rules are added on top of the defaults, and a custom rule id must not equal the default id default-mcp-30d.
{
"interval_s": 86400,
"orphan_grace_s": 900,
"reserve_bytes": 2000000000,
"rules": [
{"id": "ui-age-30d", "source": "ui", "max_age_d": 30},
{"id": "rest-keep-200", "source": "rest", "max_count": 200}
]
}The CLI command benchweave-sdk-server prune applies the rules from outside the host: the shipped defaults when no document is given, or the document’s rules added on top of the defaults. The command is root-scoped; it takes no project argument. --capture-root <path> names the capture root. --retention-rules <path> names the rules document. --dry-run lists each removal and its rule, and changes nothing. --json emits {"removals": [...], "summary": {count, bytes}}.
benchweave-sdk-server prune --capture-root /path/to/captures --retention-rules rules.json --dry-runThe prune removes exactly the plan. Each removal deletes the capture directory and its index row together. The prune also runs the orphan sweep. The sweep removes crash-left directories, which have no manifest.json, past the grace window of 900 s by default. A malformed rules document refuses with the standalone_retention_rules_invalid: prefix and exit 2. The library’s one-writer lock refuses a prune while a host serves the same root: standalone_library_locked, exit 2. Stop the host, or use the in-host schedule.
serve always runs the same evaluator inside the host on one schedule task. Without a document, the task evaluates the shipped defaults. With --retention-rules <path>, the task evaluates the document’s rules added on top of the defaults, every interval_s, 86400 s by default, over the host’s own capture root. The armed capture is excluded. Every run publishes one retention_pruned event. For every max_bytes rule whose usage crosses 0.8 rising, the host publishes a retention_quota event. A rule already above the threshold fires once, and a rule that drops below and crosses again fires again.
Upgrade note for existing capture roots: the first serve after the ruled defaults landed arms the nightly schedule over a root that may already hold old captures. The schedule task sleeps one interval before its first evaluation. The first removals of unpinned mcp captures older than 30 days land about 24 hours after the host starts. No warning event or banner comes before them; the captures page’s next-effect column is the only pre-view. The CLI prune has no delay. It applies the same defaults at invocation, so run it with --dry-run first to see the plan. Each removal deletes the capture directory (manifest, metadata, bytes) together with its index row. The retention log keeps the capture id, the manifest’s sha256, the rule and the time. Pin the captures you keep before the first run.
reserve_bytes in the same document arms the storage guard. There is no default reserve; each bench commissions its own number. At capture start, the host refuses unavailable with the standalone_storage_reserve: prefix when the worst case cannot be cleared against the reserve. A duration-bound capture with no byte reservation refuses the same way; declare max_bytes for it. During a capture, the host samples free space about every second. On a breach, the host stops the capture and publishes it with stop_reason: "reserve" and the real staged byte count. Nothing is discarded silently.
Every removal is appended to retention.log, a JSONL file in the capture root. Each row carries the capture id, the manifest’s own sha256, the rule, the time and the trigger. The trigger names who removed the capture: the schedule, the CLI, the sweep or a delete. A swept orphan is logged under the rule orphan-sweep with a null sha256. On exit, through an interrupt or a terminal close, a capture in flight is settled honestly. It is published with its real bytes, or aborted with nothing half-written. No half-written primary files remain.
The host serves the capture library at GET /captures. The table shows the capture id, started, format, bytes, source, project, tags, pinned, stop reason and the next retention effect. The next-effect column comes from the same evaluator the prune runs: prunable now by <rule>, <rule> at <date>, eligible under <rule> or kept. Filters cover project, source, tag, device and free text over id and notes; the device filter reads each capture’s metadata file. Pin and unpin are form buttons, and a pinned capture refuses deletion until you unpin it. Delete is a two-step confirmation page that names the capture id, its size and its project. The confirmed delete dispatches through the operations seam and writes its own retention.log row with trigger delete-ui.
Analyse captures (standalone host)
The standalone host serves the Analyse page at GET /analyse. The page works on the published waveform captures under the host’s capture root. You select captures, set a time window, and read the statistics that the host computes under definition benchweave-analysis/1. Every statistics block states its denominators: the sample count, the null count and the window.
The Captures list offers one check box per capture, and it lists only captures with state published and format waveform_f64le. No other capture can enter an analysis.
Select one or more check boxes, then select Analyse. The page shows one plot and one statistics table per capture. The statistics recompute each time you submit the form. The table rows are count, null_count, min, mean, max, rms and peak-to-peak. A line under each table names the capture’s manifest sha256.
Set the window with the two fields Window start (s) and Window end (s). Both times are seconds from the capture’s own start. The window is inclusive. An empty field leaves that end unbounded.
Both values must be finite numbers, and the start must not exceed the end. A window that breaks these rules refuses with the standalone_report_window_invalid: prefix. The page shows the refusal in a Refused notice.
Drag is the other way to set the window. Press the mouse on a plot and drag across a region. The drag zooms the plot to the region, fills both window fields, and submits the form. The window is then the dragged region. Both fields receive values rounded to 6 decimal places. The window fields alone are the complete path without script.
A sample counts as null exactly when it is NaN. The host counts null samples and excludes them, and it never reads a null as zero. When the window holds no non-null sample, every statistic shows the absence mark —.
The marker editor works on one capture at a time. Select exactly one capture to see it. Each marker row has three fields: a label, a time and a note.
A label is one uppercase letter A to Z. One marker exists per label, and a save with a repeated label replaces the earlier row. The note is free text. The editor pads empty rows to three, and it shows at most 26 rows.
The time is seconds from the capture’s own start. It must lie inside the capture’s displayable span, from 0 to (sample_count - 1) × sample_interval_s. A marker outside that span refuses with the standalone_report_marker_invalid: prefix.
Click a plot to write a time into a marker row. The click fills the row you last touched, or the first row with an empty time. A drag never places a marker. The drag brush owns drags. The marker time fields alone are the complete path without script.
Select Save markers to store the rows. The save rewrites the capture’s analysis.json overlay, format standalone-analysis/1. The save replaces the whole overlay with the editor’s rows, stored sorted by label. Empty rows store no marker. Your unsaved rows also ride the export. When you export, the editor’s current rows become the report’s markers for that capture.
Power modes pair the set’s voltage captures and current captures into rails. The pairing uses the units only, never the capture names. A capture with unit A, mA, uA or nA is a current side. A capture with unit V, mV, uV or kV is a voltage side. Letter case does not matter, and µ reads as u.
Without explicit rails, the first voltage side and the first current side of your selection form rail 1. The next pair forms rail 2, and so on. A current side without a voltage partner keeps its own rail, with rows for the current side only. A voltage side without a current partner forms nothing.
A rail pairs whole captures only. Both sides must declare the same sample interval and the same sample count. A mismatch refuses with the standalone_report_power_pairing: prefix.
The Power mode field names one presentation over the rails:
| Mode | Works on | More input |
|---|---|---|
battery |
rail 1 | Battery capacity (Ah), for the runtime row |
dc-dc |
rails 1 and 2 | none |
sleep |
rail 1 current | Sleep threshold, in the rail’s current unit |
load-step |
rail 1 | none |
The battery runtime row appears only when you give a capacity. The runtime is the capacity divided by the mean current. A zero or non-finite mean current omits the row, and no default capacity exists. The sleep block reads not_evaluated with reason no threshold when you give no threshold. An empty class reports its count and shows the absence mark for its mean.
The dc-dc efficiency row stays absent when the host cannot compute it, and a reason names why. The same holds for the load-step resistance row.
Every power label derives from the rail’s actual units. The power label is W only when both sides use the base units V and A. A scaled rail prints its unit product instead, for example V·mA. The charge label is Ah only for unit A, and the energy label is Wh only for a W rail. The host never rescales a value to match a label.
The power section can state an unavailability instead of rows. power_unavailable: no current series means the selection holds no current side. The section then shows no power rows, never a 0 W row. A rail with no voltage side states power_unavailable: no voltage series where the paired rows would be. Its charge row stays, because the charge integrates the current capture alone.
Export and read the analysis report (standalone host)
Select Export report to render one self-contained HTML document. The host writes the pair reports/<report id>.html and reports/<report id>.json under the capture root. The report id is rep- plus the first 16 hex digits of the document’s SHA-256. The id names the document’s own bytes. Identical inputs re-export to the same report. The second export writes nothing new, and the API result answers created: false for it.
The document holds these sections:
- a banner line
STANDALONE — no gateway, plusSIMULATED (a source capture records mock transport)when a source records mock transport; - the definitions line, which names the definition ids and states
Processed values are host-computed (uncertainty: unknown); - the context chart, with every capture’s full extent, the window shaded, and the in-extent markers drawn;
- the zoom chart, with the window at full detail, only when the window is a proper part of the extent;
- one window statistics block and one edge timing block per capture;
- the power section, when a power mode was selected;
- the assertions table, when the export carried assertion rows from the REST or MCP surface;
- the markers table, with every stored row and an in-window yes or no;
- one sources block per capture, with the unit, the sample count, the interval, the byte length, the start time, the full manifest sha256, the device, the firmware, the plugin, the descriptor sha256, the operator, the project and the notes;
- the footer, with the SDK version, the renderer pin and the re-derivable statement.
Each edge timing block uses definition benchweave-edge/1. The baseline is the mean of the first 15 % of the window’s non-null samples. The final value is the mean of the last 15 %. Each side counts at least 1 sample. A window with fewer than 3 samples, or a flat step, reads detected: no.
The rise_time row is the span from the 10 % crossing to the 90 % crossing. The settle_time row measures from the 10 % crossing. It shows the absence mark when the window’s last sample is still outside the settle band. The settle band is 2.0 % of the step, and the web export always uses this value.
An assertion row uses definition benchweave-assert/1. It names a capture and a lower bound, an upper bound, or both. The verdict is pass, fail or not_evaluated. An empty window is not_evaluated, never pass. A fail row names its reasons, below_min and above_max.
Every charge and energy row carries two coverage rows. One states the integrated span in seconds. The other states how many segments were dropped. A dropped segment is a segment where one sample of the pair is null.
The integral never steps across a gap. When no segment was counted, the value is the absence mark. No data integrated is an absence, not zero energy.
The report uses these marks where a value does not exist:
| Mark | Meaning |
|---|---|
— |
The window held no data for this value. No value was computed. |
n/a (non-finite) |
Data was present, but the statistic has no finite value. |
not_evaluated |
An assertion found no samples in its window, or a sleep block had no threshold. It never means pass. |
Before any number renders, the export reads each primary file again and recomputes its SHA-256. A capture that changed after publication refuses with the standalone_report_primary_mismatch: prefix. A refused export writes nothing, and it never creates the reports directory.
The document holds no clock value, so the same inputs give the same bytes. The JSON file beside the document records the export time, the parameters and the digests. The parameters are a replayable export input. Feed them back to reproduce the document with the same bytes.
Two limits protect the report’s shape. The report draws at most two distinct units. A larger set refuses with the standalone_report_units_unsupported: prefix. Split the export in that case.
A window that holds more than 2000000 samples for a plot refuses with the standalone_report_window_too_large: prefix. Narrow the window in that case. One export names at most 16 captures.
The export pins every source capture. A pinned capture never expires under retention, and it refuses deletion until you unpin it. Without a pin, mcp captures expire after 30 days. The ui and rest sources stay until you delete them. The capture retention section above explains the schedule. After the export, the browser lands on the report at GET /reports/<report id>. ### The MCP stdio session (the shim)
Agents speak MCP over stdio to the standalone host:
benchweave-sdk-server mcp /path/to/plugin-projectThe command does one of three things:
- Proxy. A started host owns the supervision family beside the bindings document, which comes from
--bindings <path>, orBENCHWEAVE_STANDALONE_BINDINGS, thendevice-bindings.jsonunder the working directory. Thestartcommand applies the same resolution. The stdio session then proxies to that host. The browser page and the agent share one host and one device session. The proxy sets no call timeout. A hung host holds the session, and the command says so once on stderr at startup. - Fallback. No host runs, because no pidfile exists or the pidfile names a dead process. The command serves the plugin in-process and prints one line on stderr that says so. No browser can attach to that session. The transport is the scripted mock. Captures in that session are ephemeral and go to a private temporary capture root that the command discards when the session ends normally. A fallback never touches the ambient capture root.
- Refuse. A host runs, but the command cannot verify it or use its credential file, or another process holds the library lock of the capture root. The command exits non-zero and names the fix. The
statuscommand shows the family’s verdict. When the command cannot verify a family, no lifecycle verb clears it. The refusal names the path of the stale file that remains: the pidfile, or the tokens file when no pidfile exists. Remove that file to resolve the state. The command never serves a second host beside a live one.
A .mcp.json entry:
{
"mcpServers": {
"benchweave-sdk-server": {
"command": "benchweave-sdk-server",
"args": ["mcp", "/path/to/plugin-project"]
}
}
}The stdio session holds no state of its own: it writes no pidfile, no journal row, no tokens file and no capture-root file. In proxy mode the served tools are exactly the host’s tools. The session answers initialize with instructions that name the mode. The proxy sentence carries the host URL and the pid. The fallback sentence says that no other surface can attach and that the session’s captures are ephemeral. Point the entry at the same bindings document as start, so the supervision family has one anchor.
4. Review compatibility and deployment
Use the AI device integration reviewer (main repository) with the exact revision and test evidence. Its SDK maintenance checklist covers public interfaces, contracts, generated projects, mocks, gateway compatibility and release artefacts.
The gateway now has an explicit load_otdp_plugin and OTDPBridge path for async adapter API 1.1. The bridge mediates the OTDP verbs identify, read, write, capture, stream_subscribe, stream_unsubscribe and invoke: scalar read, scalar write, single-channel capture, streaming subscriptions and pinned-contract invoke. The capture verb comes with pre-dispatch bounds checks, staged appends and a host-computed manifest. The step’s timeout_ms is the capture budget. The release smoke exercises a generated multi-module plugin through identify and read. Separate bridge tests cover write conversion and rejection paths. The OTDP loader path also serves dataset publishing and payload services. The bridge does not mediate the self_test, get_errors and reset verbs. Profile scheduling needs a native asynchronous host. The bridge does not supply it. The gateway retains the existing simulator loader for its separate synchronous interface.
A gateway integrator must first do four things:
- complete admission;
- supply the verified manifest and cache, and the descriptor;
- inject authorised scoped services;
- use the same monotonic timebase as the host’s deadlines.
The loader rechecks all listed file hashes and imports verified package code and resources in memory. This is an integrity check and namespace isolation, not an operating-system sandbox. Adapters remain trusted Python.
An SDK install does not add a plugin to a running workbench. There is no live device-install endpoint, automatic dependency installer or Docker hot-install command. A Docker deployment still needs persistent storage for verified packages, bench configuration, commissioned host services and activation at an approved idle boundary. See package formats and gateway installation (main repository).
5. Prepare the release, then approve publication
A Python wheel or source distribution and a registry release are different artefacts. Prepare the registry-required identity, exact compatibility, payload inventory, hashes, dependency locks, licence, source and build provenance, SBOM (software bill of materials) and evidence status. benchweave-sdk inventory <prepared-bundle-directory> prints file paths, byte counts and SHA-256 values. It does not create or approve a complete registry manifest.
Before you publish, show the owner the actual built files, the evidence and the remaining gaps. Hardware qualification and local commissioning remain separate. The SDK retains the project’s proprietary licence declaration. Release owners must establish distribution rights before they share it publicly.
After your submission lands as a pull request (PR), track it without asking anyone. benchweave-sdk registry status --publisher <you> --registry-clone <clone-of-benchweave-registry> reports your exact submission set. It also reports every release’s lifecycle timeline, from the committed records alone. The timeline covers publishes, advisories, yanks and transfers. This command is the contributor-facing half of the queue that maintainers see. Stages carry stage_partial flags, because PR-side review activity is not derivable from records. The maintainer’s registry queue reads it when a live gh is available.
Maintenance and release cycle
The main repository’s Package workflow builds the gateway and SDK from source distributions, then tests installed wheels in temporary environments on Linux and macOS. It generates, builds and installs an external plugin outside the checkout. It runs the plugin’s tests. It checks packaged contract bytes and versions. It exercises gateway loading. It rejects tampered code.
Run the same check locally from a checkout of the main BenchWeave repository. The script scripts/sdk_smoke.py lives there, not in this repository. Run:
uv run --no-project --python 3.13 scripts/sdk_smoke.py --out-dir dist/packagesOn a published GitHub release, a separate job repeats these checks. It then attaches the SDK wheel and source distribution to that release. A release tag triggers the publish workflow. The workflow builds the source distribution and wheel. It smoke-installs the wheel on five OS and arch lanes (Linux x86-64 and arm64, macOS arm64 and Intel, and Windows). It verifies each installed wheel’s vendored standards with benchweave-sdk sync-standards --check. It publishes to PyPI via Trusted Publishing. No release is published simply by an edit to these files or by a run of the local smoke check.
An installed SDK can verify its own vendored standards offline: benchweave-sdk sync-standards --check compares the packaged lock with the packaged tree and needs no repository checkout. An import of a standards bundle rewrites the source tree, so the command refuses to run outside a checkout (sync_requires_repo_checkout:).
When you change the SDK API, canonical contracts, template, gateway bridge or build tooling, update the affected tests and documentation together. Keep SDK distribution metadata, __version__, generated development dependency and this compatibility statement aligned. The smoke checks guard the installed distribution and contract resources.