Skip to content

Add an adapter

An adapter binds a capability identifier to something that can execute it: an instrument, a robot, a compute service, a human, or a simulator. This guide takes one from generation to a workflow that runs it.

Generation alone is not enough. opensdl adapter create writes an installable package, and nothing installs it. Until the laboratory depends on that package, the manifest fails at composition with LookupError: unknown adapter plugin, and opensdl validate reports success anyway. The steps below are the whole path.

The examples use a laboratory created by opensdl init whose environment is already synced, so that uv run opensdl resolves inside it, and an adapter named networked-balance bound to instrument.measure_mass. See create a laboratory for that starting point.

Where an adapter belongs

Put the generated package at adapters/<name>/ in the laboratory repository. That is the default destination of opensdl adapter create, and it is the right one: the generator emits a complete distribution with its own pyproject.toml, entry point, tests, and instructions. A distribution cannot live inside another distribution's import package, so it does not belong under src/<package>/.

The generated AGENTS.md also names src/<package>/adapters/. That directory is for adapter classes written by hand into the laboratory's own package, with the entry point declared in the laboratory's pyproject.toml. It is a module, not a package, and it is not where opensdl adapter create writes. Use adapters/<name>/ whenever the generator produced the code.

1. Generate the package

Run from the laboratory repository:

uv run opensdl adapter create networked-balance \
  --capability-id instrument.measure_mass \
  --destination adapters

This writes adapters/networked-balance/ containing pyproject.toml, the adapter class under src/opensdl_adapter_networked_balance/, a test, and instruction files. The pyproject.toml already declares the entry point that makes the adapter discoverable:

[project.entry-points."opensdl.adapters"]
networked-balance = "opensdl_adapter_networked_balance.adapter:NetworkedBalanceAdapter"

The entry-point name on the left is the value a manifest puts in plugin:.

2. Install it into the laboratory

Discovery reads installed distributions, so the entry point is invisible until the laboratory depends on the package. Add it to the laboratory's pyproject.toml as a dependency and resolve that dependency from the local path:

[project]
dependencies = [
  "opensdl-cli>=0.1.0a0",
  "opensdl-adapter-simulated-lab>=0.1.0a0",
  "opensdl-adapter-local-compute>=0.1.0a0",
  "opensdl-adapter-human-task>=0.1.0a0",
  "opensdl-adapter-networked-balance",
]

[tool.uv.sources]
opensdl-adapter-networked-balance = { path = "adapters/networked-balance", editable = true }

Both parts are required. The dependencies entry is what makes the package part of the environment; the [tool.uv.sources] entry is what tells uv to resolve it from the working tree rather than from an index, where it does not exist.

editable = true is what makes the adapter workable. Without it uv builds and installs a wheel, so every source edit needs another uv sync before it takes effect — a change to the adapter silently does nothing until you remember to re-sync.

Then sync:

uv sync

No OpenSDL distribution is published to a package index, so point the sync at a wheelhouse built from the framework checkout, as described in the laboratory's README.md:

uv sync --find-links ../opensdl/dist

uv sync reports the adapter as installed from its path:

+ opensdl-adapter-networked-balance==0.1.0 (from file:///.../my-lab/adapters/networked-balance)

3. Declare it in the manifest

Add the adapter and bind the capability to it in opensdl.yaml:

spec:
  adapters:
    - name: networked-balance
      plugin: networked-balance
  capabilities:
    - capability: instrument.measure_mass
      adapter: networked-balance

plugin is the entry-point name from step 1. name is how the manifest refers to this adapter instance.

4. Confirm it loads

opensdl validate does not load plugins, so it reports Manifest valid whether or not the adapter is installed. Use doctor, which composes the system and reports each adapter's health:

uv run opensdl doctor --manifest opensdl.yaml

A missing installation fails here, and the message names what is available:

LookupError: unknown adapter plugin 'networked-balance'; available: human-task, local-compute, simulated-lab

If you see that after step 2, the dependency, the [tool.uv.sources] entry, or the uv sync is missing.

5. Run it

Write a workflow that calls the capability, as workflows/balance-check.yaml:

id: balance-check
name: Networked balance check
input_schema:
  type: object
  required: [sample_id]
  properties:
    sample_id: {type: string}
  additionalProperties: false
steps:
  - id: weigh
    capability: instrument.measure_mass
    inputs:
      sample_id: ${inputs.sample_id}
outputs:
  simulated: ${steps.weigh.output.simulated}

Then run it:

uv run opensdl validate opensdl.yaml --workflow workflows/balance-check.yaml
uv run opensdl run workflows/balance-check.yaml \
  --manifest opensdl.yaml \
  --inputs '{"sample_id": "demo-001"}'

The generated adapter echoes its inputs and reports "simulated": true, so a successful run proves the wiring rather than the instrument. Policy applies as usual: the generated capability is R0 and the generated laboratory's rule allows R0 and R1 in the simulation environment.

6. Update the laboratory's own test

The generated tests/test_configuration.py asserts the exact set of adapter plugins in the manifest, so uv run pytest fails once you add one:

AssertionError: Extra items in the left set: 'networked-balance'

Add the new plugin name to that assertion. Keeping the assertion exact is deliberate: it makes an undeclared or removed adapter a test failure rather than a runtime surprise.

7. Implement the capability

The generated adapter is a placeholder that returns its inputs. Replacing it is the real work:

  • Semantic definitions. Give the capability real input_schema and output_schema values, a truthful executor_type and risk_class, a timeout_seconds, and max_retries. The runtime validates outputs against the declared schema.
  • Transport and typed failures. Own the vendor protocol inside the adapter and raise typed errors. Error strings reach unauthenticated API responses and the permanent event log, so keep credentials and endpoints out of them.
  • Health and reconnect. health() is what doctor reports. Make it reflect the connection.
  • Lifecycle. start() and close() are called by the registry around the process lifetime.
  • A simulator. Keep a simulated path so the workflow runs without the instrument. Every operational adapter needs one.
  • Conformance cases. conformance_cases() should exercise the real input shape, not the generated placeholder.

abort() is declared on CapabilityAdapter, but the runtime has no cancellation path, so nothing calls it. Implementing it today produces code that will not run. See capabilities.

Physical qualification stays deployment-specific. An adapter that returns a success is not evidence that a physical action occurred — see SAFETY.md for the records a physical operation must preserve and the controls that remain independent of this framework.

Declare retry safety before this adapter drives equipment

retry_safety decides whether the runtime dispatches an operation a second time after a transport failure, and what a timed-out task is allowed to claim happened. The generated adapter declares REPEATABLE, which is honest for a placeholder that computes an answer and touches nothing, and wrong for anything that moves, dispenses or heats. A definition that omits the field means not_repeatable, because saying nothing has told the runtime nothing.

Raise NotDispatchedError when, and only when, the command provably never left the client. It is the one failure that lets the runtime repeat a repeatable_if_not_dispatched operation, and nothing checks the claim. See capabilities.