---------------------------------------------------------------------- This is the API documentation for the benchweave_sdk library. ---------------------------------------------------------------------- ## Protocols Protocol / structural-typing interfaces Adapter(*args, **kwargs) Structural contract for a device plugin adapter. A plugin exposes a no-argument ``create_plugin()`` factory (named by the descriptor's ``integration.adapter.entry_point``) returning an adapter, which the host drives through a strict lifecycle: ``open`` binds descriptor and services, ``execute`` runs one operation, ``next_event`` polls a subscription, and ``close`` releases the session. Construction and ``open`` stay free of device I/O; each session uses a fresh adapter instance. Examples -------- A minimal read-only adapter skeleton: ```{.python} class DemoAdapter: async def open(self, descriptor, services, context): ... async def execute(self, request, context): ... async def next_event(self, subscription_id, context): ... async def close(self, context): ... ``` See Also -------- HostServices : scoped services handed over at open. OperationContext : identity, deadline, and cancellation per operation. HostServices(*args, **kwargs) Scoped host services: clocks, transport, and evidence recording. One services object is bound to an adapter session at ``open``. Adapters reach the device only through ``transfer`` and take time only from ``monotonic`` and ``utc_now``. See Also -------- Adapter : receives these services at open. OperationContext : per-operation deadline and cancellation state. OperationContext(*args, **kwargs) Per-operation identity, deadline, and cancellation state. The host supplies one context per operation. Adapters correlate request and result envelopes by ``operation_id`` and treat ``deadline_monotonic`` as the expiry point on the host's monotonic clock (``HostServices.monotonic``). Attributes ---------- operation_id Identity of the running operation; request and result envelopes carry the same value. dataset_id Host-reserved dataset correlation id, or ``None``. For data-producing invoke dispatches the host mints one id per operation and provides it here; a manifest published through ``DatasetServices.dataset_publish`` must use it, and a null value forbids publishing a new dataset. deadline_monotonic Deadline on the host's monotonic clock in seconds. Once ``HostServices.monotonic()`` reaches it, the operation must fail rather than transmit. See Also -------- Adapter : lifecycle that consumes a context per operation. HostServices : scoped services that own the monotonic clock. CaptureServices(*args, **kwargs) Host services extended with capture artifact storage. First partial implementation: :class:`benchweave_sdk.capture.StandaloneCaptureWriter` implements the three capture methods of this eight-member protocol over a filesystem backend (one capture in flight per instance) for hostless development and bench testing; the gateway's capture path is a separate implementation of the same three methods over its store. The writer is three of eight members — it supplies no ``transfer``/``close_transport`` and no clocks, so a full ``CaptureServices`` still awaits a composing runtime that binds a transport-backed services object around a writer, and no conformance check exercises the composed protocol yet. A host without capture support should hand adapters plain :class:`HostServices`. An implementation should turn the data appended under one ``capture_id`` into a single artifact when finalised and should leave nothing behind for an aborted capture. See Also -------- HostServices : the base transport, clock, and evidence surface. DatasetServices(*args, **kwargs) Host services extended with the dataset and payload surface. The pinned extension contract keeps the dataset services apart from the base transport: publishing or looking up a dataset grants no device I/O, and every context-bound member reuses the operation context's deadlines, cancellation, exception classes and scoped ownership. No implementation of this twelve-member protocol ships in this SDK yet; the gateway's dataset path is the intended first implementation, and a standalone hostless writer (the capture-writer parity) remains future work — the protocol exists so both implement one structural shape. A host without dataset support should hand adapters plain :class:`HostServices`. See Also -------- HostServices : the base transport, clock, and evidence surface. CaptureServices : the capture extension over the same base. LinkControlServices(*args, **kwargs) Host services extended with the line-reconfiguration capability. The negotiated serial transport's adapter-facing capability (issue #407): a transport whose line settings the adapter may reconfigure mid-session, one bounded attempt per call. The member reuses the operation context's deadlines and cancellation exactly as ``transfer`` does. A host without the capability hands adapters plain :class:`HostServices`; adapters detect it with ``hasattr(services, "reconfigure_link")`` — the member check is the whole test. The protocol is the capability of record, pinned like its siblings: the gateway's three-way agreement test carries a documented-gap row for the member set (the ``CaptureServices``/``DatasetServices`` precedent), so a member rename or signature drift fails there. See Also -------- HostServices : the base transport, clock, and evidence surface. CaptureServices : the capture extension over the same base. DatasetServices : the dataset extension over the same base. ## Testing doubles Deterministic, hardware-free host and context doubles ## Conformance Reusable adapter lifecycle and operation checks ## Validation Offline schema and semantic checks against the vendored contracts contract_documents() -> 'dict[str, Any]' Return the bundled standards documents keyed by relative path. Documents load once from the vendored ``standards/`` tree (falling back to the repository checkout during editable development) under keys such as ``otdp/0.2.2/otdp-runtime.schema.json``. Every document is digest-checked against the lock row that records it before it is parsed (#215 fix F1, design §3.2): a planted or unrecorded byte refuses with ``vendored_digest_mismatch:`` at load, never silently becomes the schema a descriptor validates against. validate(document: 'Any', schema_file: 'str', definition: 'str | None' = None) -> 'None' Validate a parsed document against a bundled contract, offline only. No remote retrieval is permitted: schema references resolve only against the bundled registry and fail closed when unresolved. Parameters ---------- document Parsed JSON document to validate. schema_file Contract key from ``contract_documents()``, for example ``"otdp/0.2.2/otdp-runtime.schema.json"``. definition Optional ``$defs`` entry to validate against, for example ``"operationRequest"``. Raises ------ ValueError If the document is not strictly JSON (finite numbers only) or fails the contract. validate_request(request: 'dict[str, Any]', otdp_version: 'str | None' = None) -> 'None' Validate an OTDP operation request envelope against its contract. ``otdp_version`` selects the pinned served version's runtime schema; the default is the lock's active row (derived, never a module literal). validate_result(result: 'dict[str, Any]', request: 'dict[str, Any]', otdp_version: 'str | None' = None) -> 'None' Validate a result envelope and its correlation with its request. Parameters ---------- result Operation result envelope to validate. request The request the result answers. Both documents are validated, and the result's ``operation_id`` and ``verb`` must match the request's. otdp_version Optional pinned served OTDP version for both envelopes (the default is the derived active version). Raises ------ ValueError If either envelope fails its contract or the pair does not correlate. validate_descriptor(descriptor: 'dict[str, Any]') -> 'None' Validate a device descriptor against the bundled OTDP contract. Beyond the schema, enforces the pinned semantic checks: capabilities and operation policies must describe the same verbs and parameter names must be unique (S01), parameter bounds must not be reversed (S02), and provider declarations must cohere with ``required_features`` and the corpus-owned ``otdp.*`` namespace (S04). Parameters ---------- descriptor Parsed device descriptor document. Raises ------ ValueError If the descriptor fails the schema or a semantic check. Examples -------- >>> import json >>> from pathlib import Path >>> from benchweave_sdk.validation import validate_descriptor >>> validate_descriptor( ... json.loads(Path("src/demo_plugin/descriptor.json").read_text())) validate_transport_provider(document: 'Any', otdp_version: 'str | None' = None) -> 'None' Validate a transport-provider contract document offline. The vendored ``otdp-transport-provider.schema.json`` of the pinned OTDP version (default: the derived active row) — which also holds the ``otdp.transport.*`` namespace rule for the contract's own ``feature_id`` — plus the checks JSON Schema cannot express (transport-providers §1/§3). A pinned version whose tree does not carry the provider contract schema (0.2.0) refuses by name: provider declarations are a 0.2.1+ surface, and validating them against a foreign version's schema would be serving-by-active, not serving-by-pin: - each grammar entry's ``request_schema``/``result_schema`` meta-validates as Draft 2020-12: the schema's ``{"type": "object"}`` holders admit any object, including schemas the metaschema refuses; - grammar kinds are unique as strings across the grammar: the schema's ``uniqueItems`` refuses byte-identical entries only; - no grammar kind reuses a generic §8.1 transfer kind: provider grammars extend the table, never shadow it; - the three identity equalities hold: urn-embedded version == ``version``, feature_id-embedded version == ``version``, and the feature_id name segment == the urn name segment. Raises ------ ValueError ``provider_contract_invalid:`` — structure only; JSON validity grants no authority, and admission stays a host-side act. verify_provider_pin(descriptor: 'dict[str, Any]', descriptor_path: 'Path') -> 'dict[str, Any] | None' Resolve and verify the descriptor's pinned provider contract (check lane). The provider triple resolves **relative to the descriptor's own package root** — the descriptor's directory — so a plugin and its pinned contract travel one package (transport-providers §2; the bundle-root rule governs only the root ``contracts`` array). The pin must name a contained regular file — no traversal, no absolute path, no drive-colon segment, no symlinked component — hash to the pinned ``sha256``, parse, pass :func:`validate_transport_provider`, and agree with the declaration's ``feature_id`` and ``id`` (the feature-id agreement transport-providers §7 lists as offline-provable). Direct callers passing a dict that never saw the schema get the same typed refusals, not KeyErrors. Raises ------ ValueError ``provider_contract_missing:`` (the pin does not resolve inside the package), ``provider_contract_hash_mismatch:`` (bytes disagree with the pin), or ``provider_contract_invalid:`` (the document is not a valid, agreeing contract — or exceeds the SDK's shared bounded-read cap, an SDK resource bound rather than a semantic judgement). Returns None when the descriptor declares no provider: the unbacked custom transport stays the honestly-incomplete posture, not a refusal. ## Presentation Offline presentation validation and bounded, symlink-refusing reads ## Packaging Deterministic bundle inventory helpers ## Standalone capture The three capture methods on a local filesystem, for work without a gateway ---------------------------------------------------------------------- This is the CLI documentation for the package. ---------------------------------------------------------------------- ## CLI: benchweave-sdk ``` Usage: benchweave-sdk [OPTIONS] COMMAND [ARGS]... Software-only SDK commands; no hardware access, no signing, no service. Options: --version Show the version and exit. --help Show this message and exit. Commands: adopt Write the template provenance record for a pre-copier... check Run offline descriptor schema and basic semantic checks. check-preset Validate complete settings offline. check-ui Validate a presentation candidate offline. derive-ui Derive the class-reference presentation set into a... doctor Report the project's pinned SDK version against the... inventory Print hashes for a prepared bundle; not a release... new Create a synthetic external plugin project. package Package a finished plugin into the submission artefact... preview-ui Preview the plugin on the standalone host (mock... registry Registry management: queue/status reads and lifecycle... submit Write a packaged artefact set into the registry... sync-standards Import a standards bundle into the SDK's vendored tree... timestamp Request an RFC 3161 TimeStampToken over a publisher... upgrade Move a scaffolded project to this SDK's released... verify-ui Render the project's presentation and assert the class... ``` ### benchweave-sdk new ``` Usage: benchweave-sdk new [OPTIONS] DIRECTORY Create a synthetic external plugin project. Options: --package TEXT [default: example_plugin] --with-ui Add optional read-only UI resources --help Show this message and exit. ``` ### benchweave-sdk doctor ``` Usage: benchweave-sdk doctor [OPTIONS] [DIRECTORY] Report the project's pinned SDK version against the installed one. Fully offline (issue #347 WS1b): reads the benchweave-sdk== requirement that ``new`` writes into [project.optional-dependencies] test — a mutable declaration, not a provenance record — and compares it, normalized, with the running SDK. A version mismatch is reported, never failed on; ``check`` stays the conformance gate; ``doctor`` only diagnoses. Options: --help Show this message and exit. ``` ### benchweave-sdk upgrade ``` Usage: benchweave-sdk upgrade [OPTIONS] [DIRECTORY] Move a scaffolded project to this SDK's released template tag. Fetches the tagged template from the canonical SDK repository (network; released template tags only — a development install's version has no tag and the update refuses with upgrade_tag_missing). Author-owned files survive. Conflicts are written as markers to resolve and commit — never resolved silently: typically where you and the template both changed a file, and always on an adopted project's first upgrade for any author-edited file its pre-copier base never rendered (the conflict keeps both sides). Files the template no longer carries are kept: skip-protected ones are restored to your bytes and reported. Options: --help Show this message and exit. ``` ### benchweave-sdk adopt ``` Usage: benchweave-sdk adopt [OPTIONS] [DIRECTORY] Write the template provenance record for a pre-copier project. The claim adopt makes: this tree equals a template@vN render plus author edits. Sound within the parity back-check window (v0.4.1 forward); older shapes still fail safe — deltas attribute to the author, conflicts are loud, nothing is lost. Options: --package TEXT Override the package name (default: inferred from pyproject.toml) --scaffolded-at TEXT Override the scaffolding SDK version (default: the pyproject pin) --help Show this message and exit. ``` ### benchweave-sdk check ``` Usage: benchweave-sdk check [OPTIONS] DESCRIPTOR Run offline descriptor schema and basic semantic checks. Options: --help Show this message and exit. ``` ### benchweave-sdk inventory ``` Usage: benchweave-sdk inventory [OPTIONS] DIRECTORY Print hashes for a prepared bundle; not a release manifest. Options: --help Show this message and exit. ``` ### benchweave-sdk check-ui ``` Usage: benchweave-sdk check-ui [OPTIONS] ENVELOPE Validate a presentation candidate offline. Options: --descriptor PATH [required] --resources PATH Package resource root [required] --catalogue PATH [required] --firmware TEXT --feature TEXT --panel TEXT --help Show this message and exit. ``` ### benchweave-sdk check-preset ``` Usage: benchweave-sdk check-preset [OPTIONS] PRESET Validate complete settings offline. Options: --descriptor PATH [required] --settings-schema PATH [required] --firmware TEXT [required] --action TEXT Force the action whose envelope is applied (default: resolve from the settings-schema identity) --help Show this message and exit. ``` ### benchweave-sdk derive-ui ``` Usage: benchweave-sdk derive-ui [OPTIONS] PROJECT Derive the class-reference presentation set into a plugin project. Expands the project's descriptor through the vendored class reference for its profile and writes ui/manifest.json, presentation.json, binding- catalogue.json, the effective settings schema, the preset scaffolds and the birth conformance declaration. A manifest that differs from the expander's own last output refuses without --force (the upgrade_dirty_tree posture); author edits belong in ui/manifest.overrides.json. Options: --force Overwrite a changed ui/manifest.json --help Show this message and exit. ``` ### benchweave-sdk verify-ui ``` Usage: benchweave-sdk verify-ui [OPTIONS] PROJECT Render the project's presentation and assert the class expectation. The gate: validate, derive, render through the standalone host over in- process ASGI, census the served HTML on the ui-contract §E.1 surfaces, assert the numeric expectation, the coverage arm and the declared deviation ledger. Requires the [server] extra. Options: --help Show this message and exit. ``` ### benchweave-sdk sync-standards ``` Usage: benchweave-sdk sync-standards [OPTIONS] [BUNDLE] Import a standards bundle into the SDK's vendored tree and lock. With --check and no bundle, verify the committed lock and vendored tree alone; no main-project export is read. An installed SDK (no repository checkout) supports only that --check form, against its packaged lock. Options: --check Verify the vendored tree only --help Show this message and exit. ``` ### benchweave-sdk package ``` Usage: benchweave-sdk package [OPTIONS] PLUGIN_DIR Package a finished plugin into the submission artefact set (offline). Keyless by default; --publisher-key applies the publisher's own signature at package time (the registry validates and labels, never signs). Options: --registry-clone PATH A clone of benchweave-registry (namespace rules, prior releases) [required] --source-url TEXT The plugin source repository URL [required] --revision TEXT Immutable commit digest (40- or 64-hex) being released [required] --publisher TEXT Your vetted publisher id [required] --plugin TEXT Plugin name (default: the directory name) --version TEXT Release version (default: the plugin's pyproject version) --out PATH Output directory for the artefact set [required] --capability TEXT Declared capability, repeatable: network-egress | subprocess-or-native-library | filesystem-writes; pass none for an explicit none --dependency TEXT Registry dependency /@:, repeatable --transport-triple TEXT Admitted triple @: a transport-declaring descriptor publishes --publisher-key PATH The publisher's Ed25519 private key: sign the manifest at package time (the registry validates and labels; it never signs) --timestamp-token PATH An RFC 3161 TimeStampToken (DER) over manifest.sig, from 'benchweave-sdk timestamp' or a TSA directly; binds a trusted signing time to the signature --tsa-url TEXT The TSA named in the timestamp record --licence-spdx TEXT [default: MIT] --help Show this message and exit. ``` ### benchweave-sdk timestamp ``` Usage: benchweave-sdk timestamp [OPTIONS] SIGNATURE Request an RFC 3161 TimeStampToken over a publisher signature. Options: --tsa-url TEXT The RFC 3161 Timestamping Authority URL [required] --out PATH Where to write the DER token [required] --help Show this message and exit. ``` ### benchweave-sdk submit ``` Usage: benchweave-sdk submit [OPTIONS] ARTIFACTS_DIR Write a packaged artefact set into the registry repository and branch (CR-6). Options: --registry-clone PATH Working clone of benchweave-registry; the submission branch is created here [required] --base TEXT Base branch (default: the repo's own default — origin/HEAD, else current) --open-pr Open the PR via gh when available --help Show this message and exit. ``` ### benchweave-sdk registry ``` Usage: benchweave-sdk registry [OPTIONS] COMMAND [ARGS]... Registry management: queue/status reads and lifecycle writes (offline). Every command works on a clone of benchweave-registry; nothing here touches a service, and the keyed commands take their key as a local file argument that never enters any repository or CI (CR-12). Options: --help Show this message and exit. Commands: advise Attach a security/quality advisory; lifecycle stays... publish-status Write a release's baseline status document (sequence 1)... queue Derive every submission's queue stage (CR-27): seven... status Contributor surface (CR-31): your submissions and... transfer Transfer a release to another publisher — transfer is... unlist Unlist a release: the record only — the release stays... withdraw Withdraw a submission — pre-acceptance only (contributor). yank Yank a release: status sequence+1 lifecycle yanked,... ``` ### benchweave-sdk registry queue ``` Usage: benchweave-sdk registry queue [OPTIONS] Derive every submission's queue stage (CR-27): seven stages, records-first. PR state comes from --pr-state or a live `gh pr list --json` when gh can reach the repository; without either the queue degrades loudly (stage_partial: pr_state_unavailable) rather than guessing. Options: --registry-clone PATH A clone of benchweave-registry (committed records) [required] --pr-state PATH PR-state fixture (gh-normalized shape) instead of a live gh read --help Show this message and exit. ``` ### benchweave-sdk registry status ``` Usage: benchweave-sdk registry status [OPTIONS] Contributor surface (CR-31): your submissions and release timelines. Reads committed records only — publisher-scoped exact submission sets plus each release's lifecycle event timeline. No PR state is consulted, so stages carry the same loud stage_partial disclosure as the queue. Options: --registry-clone PATH A clone of benchweave-registry (committed records) [required] --publisher TEXT Scope to one publisher (exact set) --plugin TEXT Scope to one plugin within the publisher --help Show this message and exit. ``` ### benchweave-sdk registry publish-status ``` Usage: benchweave-sdk registry publish-status [OPTIONS] RELEASE Write a release's baseline status document (sequence 1) and sign it. The durable fix for the slice-1 vacancy: every release carries a schema- complete status.json plus status.sig, so a clean clone resolves. CR-13/Q12 restated: this is the registry's process-side compensation — it changes no admission semantics; the resolver's gates (sequence, expiry, lifecycle) enforce at read time, and admission is consumed read-only. Options: --registry-clone PATH A clone of benchweave-registry (the release tree lives here) [required] --origin-key PATH The origin Ed25519 private key, a LOCAL PEM file — it never enters any repository or CI (CR-12); the registry validates and labels, never signs [required] --expires-at TEXT Status expiry (RFC 3339); default +365d, disclosed --reason TEXT [default: initial publication] --support-state TEXT [default: maintained] --support-contact TEXT Default: the publisher's vetted GitHub contact --help Show this message and exit. ``` ### benchweave-sdk registry yank ``` Usage: benchweave-sdk registry yank [OPTIONS] RELEASE Yank a release: status sequence+1 lifecycle yanked, re-signed, recorded. Advisories and support fields are preserved, not reset. CR-13/Q12 restated: yank is served state plus records — the gateway's admission gate refuses yanked releases at read time; nothing here weakens it. Options: --registry-clone PATH A clone of benchweave-registry [required] --origin-key PATH The origin Ed25519 private key, a LOCAL PEM file (CR-12) [required] --reason TEXT The yank reason (recorded in status and record) [required] --actor TEXT The GitHub identity responsible for this record [required] --kind TEXT [default: admitted-release] --help Show this message and exit. ``` ### benchweave-sdk registry advise ``` Usage: benchweave-sdk registry advise [OPTIONS] RELEASE Attach a security/quality advisory; lifecycle stays published. An advisory is not a yank: the release stays admissible and the advisory rides the served status's advisories[] under sequence+1, re-signed. CR-13/Q12 restated as for yank — process-side records, never admission semantics. Options: --registry-clone PATH A clone of benchweave-registry [required] --origin-key PATH The origin Ed25519 private key, a LOCAL PEM file (CR-12) [required] --actor TEXT The GitHub identity responsible for this record [required] --id TEXT Advisory identifier (e.g. BW-2026-0001) [required] --severity TEXT info | low | medium | high | critical [required] --summary TEXT One-line summary [required] --url TEXT https URL to the full advisory [required] --kind TEXT [default: admitted-release] --help Show this message and exit. ``` ### benchweave-sdk registry unlist ``` Usage: benchweave-sdk registry unlist [OPTIONS] RELEASE Unlist a release: the record only — the release stays admissible. No status document is read or written (CR-32/E4): the catalogue row drops at index regeneration while the release remains resolvable; unlisting is not yanking. Options: --registry-clone PATH A clone of benchweave-registry [required] --reason TEXT The unlist reason (recorded) [required] --actor TEXT The GitHub identity responsible for this record [required] --kind TEXT [default: admitted-release] --help Show this message and exit. ``` ### benchweave-sdk registry withdraw ``` Usage: benchweave-sdk registry withdraw [OPTIONS] RELEASE Withdraw a submission — pre-acceptance only (contributor). Refuses once a publish record exists ('withdraw_after_publication:'): post- signing withdrawal is an advisory or an unlist, never a silent disappearance (CR-32). The CR-56 kind tag is required — no default. Options: --registry-clone PATH A clone of benchweave-registry [required] --reason TEXT The withdrawal reason (recorded) [required] --actor TEXT The GitHub identity responsible for this record [required] --kind TEXT Required: the record's CR-56 class (admitted-release | in-tree-fixture | community-shared) — withdrawal has no honest default --help Show this message and exit. ``` ### benchweave-sdk registry transfer ``` Usage: benchweave-sdk registry transfer [OPTIONS] RELEASE Transfer a release to another publisher — transfer is re-vetting (Q9). The record's consents carry both publisher ids (the registry gate's landed semantics), the vetting reference is the canonical publishers.json# citation, and the receiver must hold a publishers.json vetting block — the same refusals the registry's records CI makes. Future releases of the plugin publish under the receiver's namespace; existing release paths are immutable history. Options: --registry-clone PATH A clone of benchweave-registry [required] --to TEXT The receiving publisher id [required] --consent-from TEXT Optional evidence text from the transferring publisher (the ids always ride) --consent-to TEXT Optional evidence text from the receiving publisher (the ids always ride) --vetting-ref TEXT Vetting citation; default and only value: publishers.json# --reason TEXT The transfer reason (recorded) [required] --actor TEXT The GitHub identity responsible for this record [required] --kind TEXT [default: admitted-release] --help Show this message and exit. ``` ### benchweave-sdk preview-ui ``` Usage: benchweave-sdk preview-ui [OPTIONS] PROJECT Preview the plugin on the standalone host (mock transport only). PRD 12 R-9: an in-process alias over ``benchweave-sdk-server serve`` — the same listener rules and pages, with the transport pinned to the scripted mock. Real-hardware serving is ``benchweave-sdk-server serve --transport serial``; this command never leaves the mock (D-6). Requires the ``[server]`` extra: a default install refuses with ``benchweave_sdk_server_extras_missing:`` and exit 2. Options: --host TEXT [default: 127.0.0.1] --port INTEGER RANGE [default: 8477; 1<=x<=65535] --allow-network --no-open --scenario TEXT Serve one of the nine baseline states (mock transport only); validated at launch, as on serve --help Show this message and exit. ``` ---------------------------------------------------------------------- This is the User Guide documentation for the package. ---------------------------------------------------------------------- ## Get started ### Get started This page takes you from an empty directory to a plugin project that passes its first `benchweave-sdk check`. Each step gives the command to run, the output you should see, and a link for the output you did not see. The commands work on Linux, macOS and Windows. You need Python 3.13 or later and the `uv` package tool. You do not need a BenchWeave gateway. You do not need hardware. All commands run offline after the installs. ## Step 0. Prepare an environment Install `uv` first. The [uv installation guide](https://docs.astral.sh/uv/getting-started/installation/) explains the options. The usual commands are: ```text # macOS and Linux curl -LsSf https://astral.sh/uv/install.sh | sh # Windows PowerShell powershell -c "irm https://astral.sh/uv/install.ps1 | iex" ``` Make a project directory and create a virtual environment in it: ```sh mkdir first-plugin cd first-plugin uv venv ``` You should see a line that ends with `Activate with: source .venv/bin/activate`. Activate the environment. Run the line for your operating system: ```sh # macOS and Linux source .venv/bin/activate # Windows PowerShell .venv\Scripts\Activate.ps1 # Windows cmd .venv\Scripts\activate.bat ``` If the activation line does not appear, see [Troubleshooting](troubleshooting.qmd). ## Step 1. Install the SDK Install the SDK with the scaffold extra. The `new` command in Step 2 needs this extra: ```sh uv pip install 'benchweave-sdk[scaffold]' ``` You should see `Installed` lines, and one of them names `benchweave-sdk`. Verify the install: ```sh benchweave-sdk --version ``` You should see the tool name and a version number, with no error. If the install stops with a no-virtual-environment error, see [Troubleshooting](troubleshooting.qmd). ## Step 2. Generate a plugin project > [!CAUTION] > A generated project is not qualified firmware and not a real instrument driver. The protocol and the test data are synthetic placeholders. Generate a synthetic plugin project: ```sh benchweave-sdk new plugins/acme/model100 --package benchweave_acme_model100 ``` You should see this message: ```text Created synthetic plugin at /plugins/acme/model100; review before hardware or publication. ``` The path in the message is the real path of your directory. On macOS, a path that you reach through `/tmp` is reported as `/private/tmp`. See [Troubleshooting](troubleshooting.qmd) for this behavior. The project contains these files, and the steps below use each of them: ```text plugins/acme/model100/ ├── pyproject.toml # build configuration and test dependencies ├── AI-GUIDE.md # the development workflow ├── CLAUDE.md # agent notes; imports the SDK-managed guide ├── AGENTS.md # SDK-managed agent guide ├── .claude/skills/ # SDK-managed workflow skills ├── src/benchweave_acme_model100/ # the import package │ ├── adapter.py # the SDK-facing adapter │ ├── protocol.py # the synthetic device protocol │ └── descriptor.json # the device and operation contract └── tests/ # the generated tests ``` The generated tree holds more files than this diagram. The [plugin developer guide](plugin-sdk.qmd) lists the complete tree. ## Step 3. Install the project and run its tests Change into the project. Install it with its test dependencies. Run the tests: ```sh cd plugins/acme/model100 uv pip install -e '.[test]' pytest ``` The last summary line contains the word `passed`. The number of tests grows when the scaffold grows, so this page does not state a number. If `pytest` reports `ModuleNotFoundError` for a package, see [Troubleshooting](troubleshooting.qmd). ## Step 4. Run the descriptor check Run the offline descriptor check: ```sh benchweave-sdk check src/benchweave_acme_model100/descriptor.json ``` You should see this message: ```text Descriptor schema and basic S01/S02/S04 checks passed; full conformance and hardware evidence remain separate. ``` This message is the first check-pass. A pass exits with code 0. A failure exits with a nonzero code and an error message. A pass means the descriptor matches the vendored schema and the basic checks. It does not mean that the plugin is conformant, admitted to a gateway, or qualified for hardware. The [plugin developer guide](plugin-sdk.qmd) explains the full scope. You do not need the S01–S18, C01–C12 and M01–M14 obligations to reach this pass. The [Glossary](glossary.qmd) explains these families. If the check fails, the message names the file and the reason. Correct the descriptor, then run the command again. ## Step 5. Build and inventory Build the project: ```sh uv build ``` You should see two `Successfully built` lines. The `dist/` directory now holds a wheel and a source archive. Print the inventory of the prepared bundle: ```sh benchweave-sdk inventory dist/ ``` You should see JSON output. Each row carries the keys `path`, `bytes` and `sha256`. There is one row for each file under `dist/`. If inventory reports a missing argument, see [Troubleshooting](troubleshooting.qmd). ## Step 6. Check the scaffold pin Check that the project's pinned SDK version matches the installed SDK: ```sh benchweave-sdk doctor ``` You should see a line that starts with `Scaffold version matches the installed SDK:`. The line ends with the installed SDK version. If doctor reports a mismatch, the project's pinned SDK version and the installed one differ. Align the pin with the SDK you use, then run the command again. ## Step 7. Where next - Replace the synthetic protocol with verified device behavior. Start with prompt 2 in `AI-GUIDE.md`. - A user interface is optional. The section below generates a second project with a UI and checks it. - Two copies of the SDK exist. [Which checkout](which-checkout.qmd) tells you which one to use. - Terms with one fixed meaning live in the [Glossary](glossary.qmd). - The full depth lives in the [plugin developer guide](plugin-sdk.qmd). - The release commands are `benchweave-sdk package` and `benchweave-sdk submit`. The registry documentation owns their walkthrough. You do not need them to reach the first check-pass. - Record the applicable S01–S18, C01–C12 and M01–M14 obligations before a release. None of them block the first check-pass. ## Optional. Check a project with a UI Generate a second project with presentation resources. Return to the `first-plugin` directory first: ```sh cd ../.. benchweave-sdk new plugins/acme/model200 --package benchweave_acme_model200 --with-ui cd plugins/acme/model200 benchweave-sdk check-ui src/benchweave_acme_model200/presentation.json \ --descriptor src/benchweave_acme_model200/descriptor.json \ --resources src/benchweave_acme_model200 \ --catalogue src/benchweave_acme_model200/binding-catalogue.json \ --firmware 1.0.0 ``` You should see this message: ```text Offline presentation checks passed; not admission or approval to apply settings. ``` The `--firmware` value comes from the generated project. The descriptor's `identity.supported_firmware` list declares it. When you write your own descriptor, use a value from your own list. > [!CAUTION] > A preview pass is not admission, not approval to apply settings, and not hardware qualification. Preview the pages in a browser. The preview serves this project's pages on the standalone host, on the scripted mock transport. The preview command needs the server extra. Install the extra once, in the environment you made in Step 1: ```text uv pip install 'benchweave-sdk[server]' benchweave-sdk preview-ui . --no-open ``` The preview serves until you stop it. Press `Ctrl+C` to stop the server. With `--no-open`, open the printed URL in a browser yourself. The startup output also prints a bearer token. REST mutations and MCP over HTTP need this token. See [Troubleshooting](troubleshooting.qmd) for the stop behavior. ### Which checkout do I use? Two copies of the BenchWeave plugin SDK exist. One copy is canonical. The other copy is an integration mount inside the gateway repository. This page tells you which copy to use and how to check the version of each. | Checkout | Location | Role | Use it for | | --- | --- | --- | --- | | The SDK repository | `madeinoz67/benchweave-sdk` | The canonical SDK | Plugin authoring, SDK installs, SDK development | | The gateway mount | `madeinoz67/benchweave` at `packages/sdk/` | A git-submodule mount for gateway integration | Reading how the gateway consumes the SDK | The gateway mount is the same source, mounted by git. The mount can lag behind the SDK repository, because the pointer moves in its own commit. Do not build a plugin from the mount. Do not report a mount defect against the SDK version it carries. Check the version first. ## Check which SDK you have Each command answers a different question: ```sh grep '^version' pyproject.toml # the version this checkout declares git describe --tags # the nearest release tag on this checkout benchweave-sdk --version # the version of an installed SDK ``` Run `grep '^version' pyproject.toml` inside an SDK checkout. Run `git describe --tags` inside a git checkout. Run `benchweave-sdk --version` in any environment with the SDK installed. ## Upgrade a tool install A tool installed with `uv tool install` does not update by itself. An old tool install can miss a new command. For example, `doctor` reports `No such command 'doctor'` on an install from before that command existed. Upgrade the installed tool: ```sh uv tool upgrade benchweave-sdk ``` Then verify the new version: ```sh benchweave-sdk --version ``` ## Where each copy is documented The SDK has its own documentation site and its own release cycle. The gateway, the canonical standards and the device-level integration guides live in the main repository. The [Get started](getting-started.qmd) page works from the SDK repository alone. The [plugin developer guide](plugin-sdk.qmd) covers the same repository in depth. ### Glossary Each term below has one fixed meaning across the SDK documentation. Technical names keep their exact spelling. CLI names refer to `benchweave-sdk` subcommands. **adapter**: The Python object that faces the SDK. It implements the operations that the descriptor declares. The file `adapter.py` in a generated project holds it. **binding catalogue**: The file `binding-catalogue.json`. It binds descriptor targets to UI variables. The gateway verifies it during admission. **C01–C12 obligations**: The capture obligation family. The vendored standards and the [device developer guide](https://github.com/madeinoz67/benchweave/blob/main/docs/device-developer-guide.md) in the main repository define them. The glossary does not restate them. **capture**: The recording of device data during an operation. The `capture` module writes captures to a local directory when no gateway is present. **check**: The `benchweave-sdk check` subcommand. It runs offline descriptor schema and basic semantic checks. A pass is not full conformance. **check-preset**: The `benchweave-sdk check-preset` subcommand. It validates a complete configuration preset offline. **check-ui**: The `benchweave-sdk check-ui` subcommand. It validates a presentation candidate offline. A pass is not admission and not approval to apply settings. **descriptor**: The file `descriptor.json`. It declares the device, its identity, its operations and its firmware policy. It is the runtime compatibility contract. **doctor**: The `benchweave-sdk doctor` subcommand. It reports the project's pinned SDK version against the installed one. A mismatch is reported, never failed on. The standalone server carries a second `doctor`, `benchweave-sdk-server doctor`. It runs five read-only triage checks over the supervision family. The same word names two entry points. **gateway**: The BenchWeave control application in the main repository. It loads plugins, runs procedures and keeps the run records. The SDK never needs a gateway. **import package**: The Python package under `src/` that you import. Select it with the `--package` option of `new`. It differs from the project directory. **inventory**: The `benchweave-sdk inventory` subcommand. It prints the hashes for a prepared bundle. It is not a release manifest. **M01–M14 obligations**: The measurement obligation family. The vendored standards and the [device developer guide](https://github.com/madeinoz67/benchweave/blob/main/docs/device-developer-guide.md) in the main repository define them. The glossary does not restate them. **plugin project**: The directory that `new` generates, such as `plugins/acme/model100/`. It holds the import package, the tests and the guides. It can stand alone as a repository. **prepared bundle**: The directory that holds built release files, such as `dist/` after `uv build`. The `inventory` subcommand reads it. **presentation**: The declarative UI of a plugin: the envelope, the manifest and their assets. Generated only with `--with-ui`. **preset**: A complete set of configuration settings with metadata about compatibility and provenance. A preset never contains collected measurements. **preview**: The `benchweave-sdk preview-ui` subcommand. It serves the plugin project's pages on the standalone host, with simulated data on the scripted mock transport. It serves until you stop it. **S01–S18 obligations**: The safety obligation family. The vendored standards and the [device developer guide](https://github.com/madeinoz67/benchweave/blob/main/docs/device-developer-guide.md) in the main repository define them. The glossary does not restate them. **standalone server**: The optional server that serves one plugin project without a gateway. Install it with the `[server]` extra. Its command is `benchweave-sdk-server`. The command family includes `serve`, `start`, `stop`, `restart`, `status`, `doctor` and `logs`. **synthetic plugin**: A project generated by `new` before any device work. Its protocol and test data are placeholders. It is not qualified firmware and not a real instrument driver. **synthetic protocol**: The placeholder protocol in `protocol.py` of a generated project. Replace it with verified device behavior before release. `AI-GUIDE.md` prompt 2 starts that work. ### Troubleshooting Each row below names a symptom, its cause, and a fix that uses a command that exists. The rows come from the recorded gaps of a first run. New rows are added when first-time users report them. ## The install reports no virtual environment **Symptom.** `uv pip install` stops with `error: No virtual environment found; run uv venv`. **Cause.** No virtual environment exists in the directory tree. The install command ran before Step 0 of [Get started](getting-started.qmd). **Fix.** Create the environment, activate it, then retry the install: ```sh uv venv source .venv/bin/activate ``` ## The created path differs from the typed path **Symptom.** You type `new /tmp/.../model100`. The message reports `Created synthetic plugin at /private/tmp/.../model100`. **Cause.** On macOS, `/tmp` is a symbolic link. The SDK canonicalizes the destination before any write, and messages report the real path. This behavior is correct. Nothing is missing. **Fix.** No fix is needed. Use the reported path. For temporary work, use `/private/tmp/...` directly. ## The inventory command reports a missing argument **Symptom.** `benchweave-sdk inventory` stops with `Missing argument 'DIRECTORY'` and prints the usage text. **Cause.** The command needs the prepared bundle directory. A bare call has no directory to read. **Fix.** Build first, then point the command at the build output: ```sh uv build benchweave-sdk inventory dist/ ``` ## `uv run pytest` reports a missing module **Symptom.** `uv run pytest` fails with `ModuleNotFoundError: No module named 'benchweave_sdk'`. **Cause.** `uv run` resolves its own environment and dependencies. It does not use the activated environment from Step 0. **Fix.** Use the activated environment. Run the tests with `pytest` after `source .venv/bin/activate`. The [Get started](getting-started.qmd) page teaches this one path. ## An installed tool misses a command **Symptom.** A subcommand from this documentation reports `Error: No such command ...` on your machine. **Cause.** A tool installed earlier with `uv tool install` is an older release. Tool installs do not update by themselves. **Fix.** Upgrade the installed tool, then verify it: ```sh uv tool upgrade benchweave-sdk benchweave-sdk --version ``` ## The `check-ui` firmware value is unclear **Symptom.** A `check-ui` example passes `--firmware` with a value, and you do not know where the value comes from. **Cause.** The value is not a free choice. The descriptor's `identity.supported_firmware` list declares the supported firmware versions. A generated project declares its synthetic version there. **Fix.** Read the list in your `descriptor.json`. Use one of its values with `--firmware`. ## The preview does not stop **Symptom.** `benchweave-sdk preview-ui` keeps running. It does not return to the prompt. **Cause.** The preview serves until you interrupt it. This posture is by design. **Fix.** Press `Ctrl+C` to stop the server. The command then exits. ## Plugin development ### 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](https://github.com/madeinoz67/benchweave/blob/main/docs/device-developer-guide.md) (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](https://github.com/madeinoz67/benchweave/blob/main/docs/device-developer-guide.md#repository-layout-for-device-plugins): `plugins///`, with `src//` 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](https://github.com/madeinoz67/benchweave) 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: ```sh # 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-sdk ``` For the newest unreleased code, install from the default branch: ```sh pip install git+https://github.com/madeinoz67/benchweave-sdk.git ``` Full 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: ```sh uv build packages/sdk --out-dir dist/sdk ``` In your development directory, create an environment. Then install the built wheel from the checkout's `dist/sdk/`: ```sh 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]' pytest ``` Keep 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` 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//**`, `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. ```text 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//`; they never enter the plugin wheel. The seeded package skills inside `src//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](https://github.com/madeinoz67/benchweave/blob/main/docs/develop-your-device.md) (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: ```sh pytest benchweave-sdk check src/benchweave_acme_model100/descriptor.json uv build ``` Generated 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 {#per-pin-standards} 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//` 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) {#transport-providers} 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's `feature_id` is not in `required_features`. - `provider_transport_undeclared:` means that an `otdp.transport.*` feature is required but no provider on the custom transport declares it. It also fires when the provider's `feature_id` is not a sanctioned `otdp.transport./` id. Such a declaration is one that nothing admissible can ever back. - `unknown_otdp_feature:` means an `otdp.*` 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 pinned `sha256`. - `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`](../reference/cli/check_ui.qmd) and [`check-preset`](../reference/cli/check_preset.qmd) validate presentation candidates offline. [`preview-ui`](../reference/cli/preview_ui.qmd) 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](https://github.com/madeinoz67/benchweave/blob/main/standards/plugin-ui-preview-v1/preview-document.schema.json) (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 ` 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 ` 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. ::: {.callout-caution} 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. ```json { "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//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. ```python 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. ::: {.callout-caution} 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: `.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-`) 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. ```python 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: ```python 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": }`. 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: 1. The host refuses the request while a receive is in flight. 2. 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. 3. The host reopens the port at the boot settings, with the new baud in place. 4. 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`. ```json { "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 ` names the capture root. `--retention-rules ` names the rules document. `--dry-run` lists each removal and its rule, and changes nothing. `--json` emits `{"removals": [...], "summary": {count, bytes}}`. ```sh benchweave-sdk-server prune --capture-root /path/to/captures --retention-rules rules.json --dry-run ``` The 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 `, 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 `, ` at `, `eligible under ` 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/.html` and `reports/.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`, plus `SIMULATED (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/`. ### The MCP stdio session (the shim) Agents speak MCP over stdio to the standalone host: ```sh benchweave-sdk-server mcp /path/to/plugin-project ``` The command does one of three things: 1. **Proxy.** A started host owns the supervision family beside the bindings document, which comes from `--bindings `, or `BENCHWEAVE_STANDALONE_BINDINGS`, then `device-bindings.json` under the working directory. The `start` command 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. 2. **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. 3. **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 `status` command 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: ```json { "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](https://github.com/madeinoz67/benchweave/blob/main/docs/ai-device-reviewer.md) (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](https://github.com/madeinoz67/benchweave/blob/main/docs/develop-your-device.md#package-format-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 `](../reference/cli/inventory.qmd) 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 --registry-clone ` 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: ```sh uv run --no-project --python 3.13 scripts/sdk_smoke.py --out-dir dist/packages ``` On 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.