Skip to content

Sidecars And External Processes

A sidecar is a native process declared in Rust and supervised by the host. Dokime integrations keep their suites and typed domains in the same codebase and export them from the loaded sidecar file before initialization. The traits under dokime::plugin are the authoring API and the #[dokime::…] attributes are the path most authors take to them; they live behind the default-on derive cargo feature, the way serde ships its derives. The sidecar crate depends on dokime.

Start with your first plugin and cargo dokime new. The embedded starter is a complete small bench that uses every contract on this page and nothing else, so the fastest way to read them is to generate one. This page explains those contracts and the richer examples you can adopt as your plugin grows.

A plugin is two halves. What the host must know before the plugin runs is literal data on the attribute, because a declaration scan reads it out of a binary it never executes. What carries values or code is the trait implementation beside it.

  1. #[dokime::sidecar] binds the artifact id, the Cargo-derived version and description, the restart policy, and the catalogs of types this plugin contributes.
  2. impl dokime::Plugin declares the types — Config, Error, Resources — then the settings, then the lifecycle: initialize, resources, cleanup.
  3. Every contribution is a type with an attribute over its own inherent impl, listed in a catalog enum the sidecar attribute names.
  4. #[dokime::requires(id, version)] declares an artifact dependency.
#[dokime::sidecar(
id = "device-bench",
suites(suites::Suites),
samplers(samplers::Samplers),
listeners(listeners::Listeners),
routes(routes::Routes),
restart = "never",
)]
pub struct Device {
sensor: Arc<Sensor>,
}
impl dokime::Plugin for Device {
type Config = Config;
type Error = Error;
type Resources = Resources;
fn settings() -> Settings {
Settings::new()
.text_area("sensor_port", "Sensor port")
.described("Serial port of the bench sensor.")
.default("/dev/ttyUSB0")
.required()
}
async fn initialize(config: Config, ctx: &dokime::plugin::Context) -> Result<Self> {
Ok(Self {
sensor: Arc::new(Sensor::open(ctx.setting("sensor_port")?, config.ambient_c)?),
})
}
fn resources(&self) -> Resources {
Resources { sensor: Arc::clone(&self.sensor) }
}
async fn cleanup(self) {
self.sensor.close();
}
}

There is no second lifecycle block and no generated shim between the two: the harness and the host both call the trait. cleanup takes self by value because the process is ending, and answers nothing — a failure there has nowhere left to travel, so log what could not be closed. permissions, prompts, documents and appearance are the trait’s other definitions and default to empty.

A type belongs in an associated type, so config = … and error = … on the attribute are refused, naming the associated type to write instead.

A sidecar id and a route id are wire identities: lowercase ASCII letters, digits, dots and hyphens, starting with a letter. An id defaulted from a Rust type name is checked before it is forwarded, so a PascalCase route type is refused on the line it is written, naming id = …. Write ids explicitly, and namespace them by plugin — thermo.status, not status.

The host inventories every catalog in a declaration pass that never runs initialize. Nothing may be registered there: initialize opens handles and reads the settings and secrets the host admitted, and nothing else. The suite collection supplies the runtime factories, and the SDK creates their execution owner once per initialized plugin.

Execution evidence travels through the drain protocol. Build and install the executable through a plugin bundle, rather than copying a native file into runtime storage.

A plugin declares one error and answers with it everywhere. src/error.rs holds a concrete thiserror enum and the alias every fallible signature uses:

#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("sensor: {0}")]
Sensor(#[from] crate::sensor::Error),
#[error("pulse sample {index} carries no numeric `{column}`")]
Unmeasured { index: usize, column: &'static str },
}
pub type Result<T, E = Error> = std::result::Result<T, E>;

impl dokime::Plugin names it once, as an associated type. It must implement std::error::Error + Send + Sync + 'static:

impl dokime::Plugin for Device {
type Config = Config;
type Error = Error;
type Resources = Resources;
// …
}

initialize, unbound routes, steps, criteria, samplers, listeners and cleanup answer with Result<T>; the macros read the type through the compiler, so any Result alias works. A route bound to a domain declaration is the one boundary that answers with a foreign type, ActionDispatcherError. The plugin’s own cleanup answers nothing: at shutdown there is no frame left to carry a failure home, so what could not be closed is logged there.

The SDK turns an author error into one dokime::plugin::Diagnostic at the boundary where it calls into authored code: the return of initialize, a step, a criterion, a sampler’s start or sample, a listener, a route, cleanup. The diagnostic carries the error’s own Display, its full source() chain, the #[track_caller] location of that boundary, a span trace naming the execution, template, step or stream and plugin, and a backtrace. Capture is unconditional and RUST_BACKTRACE is never consulted, because a failure that reproduces once is exactly the failure whose backtrace nobody thought to ask for. dokime::plugin::sidecar::run also installs a panic hook, so a panic inside a step becomes the same diagnostic and the execution still reports one terminal outcome instead of a dead process.

error: step `hold` failed in thermo.soak.hold
caused by: sensor: read timed out after 1s
caused by: serial port: Resource temporarily unavailable (os error 11)
at plugins/thermo/src/suites/soak/hold.rs:71
in step step=hold
in execution execution=0199 template=thermo.soak.hold plugin=thermo-bench
backtrace:
0: thermo_bench::suites::soak::hold::Soak::hold
1: dokime::execution::run::walk

Those caused by lines are the chain, so give the enum real variants with #[from] sources. .map_err(|e| Error::Something(e.to_string())) folds the cause into the message and leaves the operator, and the stored failure record, with one line. The stored form is redacted: home directories and account names are stripped and paths are made repository-relative.

plugins/mini is a shipped example with a vertical suite module, one implicitly sidecar-executed workflow, and one process-wide sampler that keeps its own machine reader between ticks. Inspect it for suite and sampler composition after running the generated starter. The following repository check applies to a Dokime source checkout:

Terminal window
mise exec -- cargo test -p mini-sidecar

The check builds and tests the direct declaration. It creates no manifest or archive.

Standalone plugins use one crate. App-backed plugins use app-runtime for application definitions, app for its thin developer facade, and app-plugin for the Dokime adapter. The full application example is plugins/synthetic, which demonstrates that composition and a simulated device bench. These shipped examples live under plugins/; a generated plugin can live in its own repository.

A template’s inputs are one struct, written once. The field’s type is the parameter’s type, the field’s name is its id, and #[parameter(…)] carries the rest:

#[dokime::parameters]
pub struct SoakParameters {
#[parameter(label = "Target temperature", unit = "°C", default = 60.0)]
pub target_c: f64,
#[parameter(label = "Samples to hold", default = 5, min = 1)]
pub hold_samples: u64,
#[parameter(label = "Operator")]
pub operator: Option<String>,
#[parameter(label = "Heater ports", min_items = 1, max_items = 4)]
pub heater_ports: Vec<String>,
#[parameter(label = "How the sweep runs")]
pub sweep: Sweep,
}
#[dokime::choice]
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Sweep {
Fast,
#[choice(label = "Slow and careful")]
Slow,
}

The template names it once and every stage is handed the decoded value:

#[dokime::template(id = "bench.soak.hold", label = "Hold", parameters = SoakParameters)]
impl Hold {
async fn initialize(
parameters: &SoakParameters,
ctx: &mut InitializationContext<'_>,
) -> Result<Self, Error> { … }
#[dokime::step(label = "Hold")]
async fn hold(&mut self, ctx: &mut StepContext<'_>) -> Result<(), Error> {
let wanted = ctx.parameters().hold_samples;
…
}
#[dokime::criterion(label = "Stayed in band")]
fn in_band(ctx: &CriterionContext<'_>) -> Result<bool, Error> {
let target = ctx.parameters().target_c;
…
}
}

Option<T> is the optional form and Vec<T> a list; #[dokime::choice] on an enum is a closed set of values and Vec<Enum> a multiple choice. Any other field type is a validated value: implement dokime::ParameterValue for it, name the base the host carries, and refine it — the refusal message is what an operator reads beside the field.

impl dokime::ParameterValue for Celsius {
type Base = f64;
fn refine(base: f64) -> Result<Self, String> {
dokime::parameters::refined(base)
}
}

A space and a list are different declarations, and the difference is how many cases they generate. A field whose default is a space — #[parameter(default = ParameterDefault::space(["fast", "slow"]))] mode: String — is an axis: previewing the cycle produces one case per value. A Vec<T> field is one value belonging to one case. A list is never expanded into cases, however long it is.

A list’s element type is T, min_items = n and max_items = n bound its length, Option<Vec<T>> lets it be absent, unit = ".." describes the elements, and a #[dokime::choice] element makes every element a choice. An operator edits it in the GUI’s list editor — one typed input per element, with add, remove, and reorder — because the definition serializes as value_type: "array" alongside its element type and bounds. A list coerces every element to the element type and refuses a bare scalar rather than wrapping it into a one-element list. Parameter::list(name, label, element) is the same declaration for a host or generator that builds one at runtime.

min_items and max_items are part of the declaration, so the operator’s form shows them and the host refuses a case that leaves them before a run starts. min and max are checked once, when the execution starts, because a ParameterDefinition carries no numeric bound yet. Either way a case outside them is refused by the field that does not fit rather than run until whichever step reads it first fails. A template with no parameters names none and is handed (). The raw resolved map stays reachable as ctx.raw_parameters() for the rare reader that works over whatever a case carried rather than over what this template declared.

Each field also carries the handle a plot references it by — SoakParameters::target_c() — so a rename moves the reference with it instead of leaving a string behind that nothing checks. dokime::Parameter remains the programmatic path for a host or a generator that builds declarations at runtime.

A process-wide stream is a Sampler: an instance with a lifetime, not a function on a timer. The row type and the sampler that measures it live together in one file under src/samplers/, and src/samplers/mod.rs holds only the catalog enum.

use dokime::plugin::Tick;
/// The row declares the channel, which is the only place its id is written.
#[dokime::channel(id = "device.temperature", label = "Temperature")]
pub struct TemperatureRow {
#[row(unit = "s")]
pub elapsed_s: f64,
#[row(unit = "°C")]
pub celsius: f64,
}
pub struct Temperature {
sensor: Arc<Sensor>,
}
#[dokime::sampler(interval_ms = 100)]
impl Temperature {
async fn start(resources: &Resources) -> Result<Self> {
Ok(Self { sensor: Arc::clone(&resources.sensor) })
}
async fn sample(&mut self, tick: &Tick) -> Result<TemperatureRow> {
Ok(TemperatureRow {
elapsed_s: tick.elapsed().as_secs_f64(),
celsius: self.sensor.read_temperature().await?,
})
}
}
src/samplers/mod.rs
#[dokime::samplers]
pub enum Streams {
Temperature,
}

The plugin names that enum once, on #[dokime::sidecar(samplers(Samplers))]. Discovery is the enum: it lists types, so splitting the producers across files costs nothing.

start runs when the host takes the first lease on the channel, sample once per tick on the sampler’s own task, and stop when the last lease ends. What a measurement needs between ticks lives on the struct — a device reader, a connection, a running count — rather than behind interior mutability in the bundle every execution shares. A sampler that opened a port, a file or a child process closes it in async fn stop(self), which runs on that sampler’s own task before the plugin is handed back its own handles. A stateless sampler is a unit struct, and that is the whole cost of the shape.

The attribute names neither an id nor a label: Sampler::Row declared the channel and INTERVAL is the cadence, so there is one declaration and it lives with the producer. #[dokime::sampler] over an inherent impl block is sugar for dokime::Sampler; a sampler with something to say about its own lifetime writes the trait out by hand.

sample is async, which is what lets a sampler read a device that answers slowly. A call that overruns its cadence delays only its own next tick, and the instants it missed are skipped rather than burst. Every sampler owns its task, so one slow device never holds up another channel, and a failure in start or sample ends that one channel and is captured as a diagnostic. Sampling waits one full interval before its first row.

The host’s standing demand is one lease on every channel, and repeating it is not a second lease. A channel nobody is watching holds nothing open; leasing it again builds a fresh instance rather than reviving the old one. Shutdown stops and joins every running sampler before the plugin’s cleanup runs, so whatever start opened is closed before the plugin closes its own.

A template’s own sampler is a different thing. It measures one attempt’s handles, is captured for that attempt without being asked for, and has no lease to live by, so it stays an associated function inside the #[dokime::template] block and keeps the id, label and interval_ms form.

A watcher checks on a cadence and publishes records when something changes. A quiet check returns Ok(()) without publishing; the author owns the baseline, change detection, and any debounce. Its authoring forms mirror samplers: a global watcher owns an instance, and a template watcher is an associated function over that attempt’s resources.

struct ConnectionWatch {
device: Device,
connected: bool,
}
#[dokime::watcher(interval_ms = 100)]
impl ConnectionWatch {
async fn start(resources: &Resources) -> Result<Self, Error> {
let device = resources.device.clone();
let connected = device.connected().await?;
Ok(Self { device, connected })
}
async fn check(
&mut self,
publisher: &dokime::plugin::Publisher,
_tick: &dokime::plugin::Tick,
) -> Result<(), Error> {
let connected = self.device.connected().await?;
if connected != self.connected {
publisher.publish(ConnectionChanged { connected }).await?;
self.connected = connected;
}
Ok(())
}
}
#[dokime::watchers]
enum Watchers {
ConnectionWatch,
}

Here ConnectionChanged is a #[dokime::record] type. Name the catalog with watchers(Watchers) and the event with records(ConnectionChanged) on #[dokime::sidecar]. The watcher starts with the plugin, without a telemetry lease or execution. Its records take the same host publication path as a listener’s records. An optional async fn stop(self) releases anything acquired in start; the SDK gives it one second before dropping the future.

Inside a runnable #[dokime::template] block, write a function instead:

#[dokime::watcher(interval_ms = 100)]
async fn watch_connection(
resources: &OwnResources,
publisher: &dokime::plugin::Publisher,
_tick: &dokime::plugin::Tick,
) -> Result<(), Error> {
if let Some(connected) = resources.take_connection_change().await? {
publisher.publish(ConnectionChanged { connected }).await?;
}
Ok(())
}

Declare the emitted record in the template’s records(ConnectionChanged), or on its suite when every template shares it. Keep persistent observation state in OwnResources, using interior mutability where needed. The watcher starts after resources and traceability are ready, and settles before cleanup and criteria. Its records belong to this execution: its steps can await them through ctx.listen::<ConnectionChanged>() and its criteria can read them through ctx.records::<ConnectionChanged>(). They are delivered locally once; the host’s echo does not deliver them a second time. Ordinary records published by a step still do not satisfy that step’s own inbox.

Both forms wait one interval before their first check, run checks sequentially, and skip missed ticks. A check may publish zero or several records. An error or panic logs a diagnostic and stops only that watcher, without restarting it or failing the procedure automatically. Shutdown cancels a pending async check; synchronous blocking work still needs the device API’s own cancellation support.

Not everything arrives on a cadence. For data that comes on the device’s schedule, declare the channel on its row type and push from a step:

#[derive(Clone, Debug)]
#[dokime::channel(id = "device.events", label = "Device Events")]
struct EventRow {
#[row(role = state)]
event: String,
}

#[dokime::channel] declares the columns as well as the channel, so a row type that names its home does not also derive dokime::TelemetryRow — writing both is refused.

A sampler’s row declares its home too. Sampler::Row is the channel declaration, so a process-wide sampler writes its id nowhere else. A template’s own sampler names its channel on the #[dokime::sampler] beside it, and the row declares the same home when a step needs to wait on those rows by type: ctx.listen::<Row>() only means one channel when the type says which. A shape with no home is still publishable under the id at the call; it is simply not listenable, because no one channel is meant.

A template lists a pushed channel in #[dokime::template(channels(EventRow))], which is what binds the contract to that template. A step then publishes one row with ctx.publish(row), or holds a handle open with ctx.channel::<EventRow>().open() and pushes into it for as long as the handle lives.

Where one row shape serves several channels, derive dokime::TelemetryRow on it and declare each channel as a type of its own:

#[dokime::channel(id = "device.protection.samples", label = "Protection", row = SampleRow)]
struct ProtectionSamples;

A step then says which channel a reading is evidence for: ctx.channel::<SampleRow>().into::<ProtectionSamples>(). There is no string anywhere — a channel type whose declared shape is not this one does not compile at that call, and a shape that declared a home of its own cannot be carried by a channel type at all.

To read a channel another sidecar owns, declare the columns you read and mark it foreign:

#[dokime::channel(id = "hnm.gateway.rssi", foreign)]
struct GatewayRssi {
#[row(unit = "dBm")]
rssi: f64,
}

The contract this writes is marked foreign and carries only the columns named here, which is what lets the host check at admission that they are a subset of the owner’s with matching types; the flag reaches the host today and that check is not written yet. A foreign channel is read and never published: ctx.publish of one, and ctx.channel of one, do not compile.

A record used to be spelled three times: a string id, a contract built field by field in a suite’s records(), and a request filled in at the step. Now the struct is the declaration, and it lives in the file of whatever publishes it — beside the step that files it.

#[dokime::record(id = "thermo.soak.held", label = "Hold reached", severity = info)]
pub struct HoldReached {
pub samples: u64,
#[record(unit = "°C")]
pub celsius: f64,
pub unit: Option<String>,
}

The fields are the contract’s fields, resolved by type rather than by how they are spelled, and the contract is strict: a detail the struct does not declare is not one that can be published. Name it once on the template that publishes it, #[dokime::template(records(HoldReached))]; a suite names a record only when every template it owns publishes it, and #[dokime::sidecar(records(...))] names the ones a plugin publishes with no run behind them.

Which kind of evidence a value is was never the author’s decision — it followed from how the value was declared — so it is a question the compiler answers now:

ctx.publish(HoldReached { samples, celsius, unit }); // a record
ctx.publish(EventRow { event: "clamped".into() }); // a measurement

A shape that declared no home is refused at the call rather than at admission. The severity on the attribute is the declared default; an occurrence that knows better says so at the call with value.severity(RecordSeverity::Error).

Inside a step, publication is buffered into the run’s own drain and cannot fail — the buffer is the run’s and the run already owns it.

ctx.listen::<T>() answers a standard stream, for a declared record type or for a row type whose channel was declared. The adapters are the ones Rust already has, imported once:

use dokime::prelude::*;
let connected = ctx
.listen::<dokime::host::ToolConnectionAcquired>()
.filter(move |unit| unit.resource_id == serial)
.timeout(Duration::from_secs(30))
.next()
.await
.transpose()?;
ctx.info(format!("unit connected"));
// This attempt's own sampler: an exact number of rows rather than a length of
// time, so what the run measured does not depend on how quickly it got going.
let held: Vec<SoakRow> = ctx.listen::<SoakRow>().take(samples).collect().await;

.timeout(d) yields Result<T, Elapsed> per item, which is what makes it read as the deadline it is; Elapsed reaches your own Error through #[from], so ? is all a step writes. A bare .next() waits for as long as the run does, because how long a bench may take is not the SDK’s to decide, and cancellation ends the step that is waiting.

Where a subscription starts follows from what it listens to rather than from an exception. A channel is a stream of measurements and has no “before” to reach: a row already measured is already the run’s evidence, which a criterion reads. An execution’s record inbox is a bounded queue the host handed that run, so it starts where the run started and a record filed before the step reached its await still satisfies it. A record the execution published itself is passed over: it comes back through the host like any other, and answering a step with its own words would settle the question it asked somebody else.

A criterion reads both kinds of evidence typed — ctx.records::<HoldReached>()? for records, ctx.samples::<SoakRow>() for measurements, and ctx.samples::<dokime::host::System>() or ctx.samples::<ProtectionSamples>() where the channel is the runtime’s or one of several a shape serves. Rows come back as the shape that channel declared, so a criterion reads row.celsius rather than matching a Value out of a map.

Plenty of what a bench has to react to belongs to no run. A unit is usually connected before the attempt that tests it is scheduled, and the right answer is often to get ready for the next one. That is a listener: a type with hear, run on the plugin’s own task for every matching record the host files, whichever execution it belongs to or none at all.

src/listeners/unit_connected.rs
use dokime::records::host::ToolConnectionAcquired;
pub struct UnitConnected;
#[dokime::listen]
impl UnitConnected {
async fn hear(resources: &Resources, acquired: ToolConnectionAcquired) -> Result<()> {
resources.sensor.prepare_for(&acquired.resource_id).await?;
Ok(())
}
}
src/listeners/mod.rs
#[dokime::listeners]
pub enum Listeners {
UnitConnected,
}

The record is named by its type, in hear’s own argument, so the id the host forwards, the id matched at run time and the fields the body reads are one declaration — and a field the record does not declare is a compile error. The host’s own records live under dokime::records::host, which is why this one fires without a second plugin installed beside it.

Naming that catalog on #[dokime::sidecar(listeners(Listeners))] is also what declares the id to the host, which has to know what to forward to a sidecar that has not started yet. #[dokime::listen] over the inherent impl block is sugar for dokime::Listener; a hand-written implementation is the same thing spelled out. A listener reads the plugin’s process-wide resources and never a template: that belongs to one attempt, and a listener belongs to the process. Each firing runs on its own task and is handed its own record, so a slow listener holds up neither the next record nor a run waiting on one, and what a listener changes it changes through interior mutability in the resources every execution and every sampler already reads.

A listener sees records in the host’s admission order, not in publication order across plugins; there are no sequence numbers. Listening is live: a plugin the host restarted under its restart policy resubscribes and hears what is filed from then on, and is not replayed what it missed.

A listener that says something back declares &Publisher between its resources and its record in hear; one that only reads its resources leaves it out and is called without one. It has no run to publish into, so what it is handed instead is the road out of the process — publisher.publish(value).await, taking the same declared values ctx.publish does. That one is awaited and answers, because the queue it lands in is the host’s and can be closed; the asymmetry mirrors who owns the buffer. The host admits it against the contracts #[dokime::sidecar(records(...))] declared, stamps the publishing plugin as its source, and forwards it to every installed plugin’s listeners and to every live step listening for it — a process-scoped record has no execution scope at all, which is what makes it visible that widely.

Route signatures can import Call, Result, and Status from dokime::plugin::routes. A status handler returns Status::ready().field("connected", true); SDK errors convert through ?.

A route is a type with handle, in its own file under src/routes/, with the action declaration it binds beside it:

src/routes/reset.rs
pub struct Reset;
#[dokime::route(id = "device.reset", deadline_ms = 2_000, action = declaration)]
impl Reset {
async fn handle(
plugin: &mut Device,
context: dokime::domains::ActionDispatchContext,
input: Option<dokime::core::Value>,
idempotency_key: Option<String>,
) -> Result<dokime::core::Value, dokime::domains::ActionDispatcherError> {
// Validate input and use the host-supplied context and key.
plugin.reset(context, input, idempotency_key).await
}
}
src/routes/mod.rs
#[dokime::routes]
pub enum Routes {
Reset,
}

declaration() returns the existing ActionContribution; the SDK sets its entrypoint to this route’s actual method. #[dokime::sidecar(routes(Routes))] collects the declarations and serves the handlers, and the route manifest the host scans out of the built binary is made from that same list. No host adapter or separate Plugin::actions() inventory is needed.

#[dokime::route] over the inherent impl block is sugar for dokime::Route. The four bindings a route may carry do not share one handler shape, so the attribute reads whichever shape the block wrote. A plain route answers its own reply; route(status) is a read-only handler taking ActionDispatchContext and returning DispatcherStatus; route(options = declaration) takes an optional Value and returns Vec<OptionProviderOption>. A mutating health action must not be exposed as settings status. Settings only calls an explicitly declared status route and keeps its two-second timeout.

The host validates bindings against that executable’s complete served inventory before admission. Actions, options and settings use registration schema 2, which identifies the SDK domain request/reply carrier; older live transport registrations are refused. Static contributions have no invocation method. Previously persisted catalogs remain readable. User input is separate from trusted host context and idempotency, and each route retains its deadline.

A suite runs entirely inside the sidecar that registered it. The sidecar streams telemetry and records back to the host over a standard start/drain/cancel protocol while the case executes. plugins/synthetic is the reference implementation — its synthetic.device.steady suite measures one execution-owned AC DUT and executes inside the same sidecar that also serves the read-only synthetic.status route and boots an embedded Harmos app.

plugins/synthetic/
app/src/lib.rs # thin developer facade -> app-runtime
app-runtime/src/state.rs # durable application state
app-runtime/src/transactions/ # committed application changes
app-runtime/src/app.rs # Harmos identity, aliases, and the entry verbs
app-plugin/src/lib.rs # the catalogs, the settings, the lifecycle
app-plugin/src/listeners/connection.rs # what the bench hears between runs
app-plugin/src/routes/status.rs # read-only plugin readiness
app-plugin/src/suites/steady/measurement.rs # the measuring procedure

Every suite the plugin names executes in the plugin. Their existing IDs preserve recorded history; the feature modules describe what each workflow does. A standalone plugin needs only one crate, as shown in plugins/mini.

Every suite is sidecar-executed; the workflow defaults to the suite id. Close template composition with a #[dokime::templates] enum:

#[dokime::templates]
pub enum SteadyTemplates {
Steady,
}
#[dokime::suite(
id = STEADY_SUITE,
label = "Steady-State Testbench",
templates(SteadyTemplates),
)]
impl SteadySuite {}

The block is empty. There is no records() and no telemetry() here: a record is declared in the file of whatever publishes it and named on that template’s attribute, a channel is declared by its producer, and the suite publishes what its templates declared. The plugin names the closed suite set once, on #[dokime::sidecar(suites(Suites))].

The host dispatches whichever sidecar registered the owning suite. There is no execution argument, separate sidecar id, or native adapter.

2. Declare strict telemetry and record contracts

Section titled “2. Declare strict telemetry and record contracts”

The producer declares the contract, and the declaration is closed. A template’s own sampler declares its channel on the #[dokime::sampler] beside it; a channel a step pushes into declares itself on its row type with #[dokime::channel]. Either way the columns come off the struct’s fields and their #[row(…)] attributes, validation is strict, and a value the struct does not declare is not one that can be published. declared already carries the canonical timestamp column (Number, role Time) and defaults default_x to it, so a row declares its own elapsed time only when it wants a second time axis.

Those contracts cover the rows the template pushes itself. The global channels it wants captured alongside them — the host’s, or another plugin’s — are named by type on the template attribute, and Synthetic’s Steady names all six the host offers:

#[dokime::template(
id = STEADY_TEMPLATE,
label = "Steady-State Measurement",
plugin = crate::Resources,
samples(
host::System,
host::Network,
host::Wifi,
host::WifiLatency,
host::WifiChannelOccupancy,
host::Bluetooth,
),
channels(SupplyEventRow),
records(Initialized, Started, Completed),
)]

No cadence appears there, because none of it is the requester’s to choose: each channel has one sampler, that sampler declared how often it measures, and every execution leasing the channel shares that one running instance and its tick — so two runs reading the same channel read rows that agree. A faster read of the same thing is a second declared channel, not an override. The host takes the leases for the execution’s duration, starts an instance that is not already running in whichever process owns it, captures its rows into that run’s evidence, forwards them across the sidecar boundary when the channel belongs to another sidecar, and stops the instance when the last lease is released; a dashboard watching the same channel is one more lease. A template that names a channel no installed plugin publishes is refused at admission, naming the plugin, the template and the channel.

A template’s document is one ordered list of sections, and every one of them is visible: there are no views and nothing to select. Order is the layout, and the host puts the steps and criteria in front of what the template wrote and the records, artifacts, media, traceability and notes behind it.

A plot names its columns through the row type that declared them, so a renamed column is a compile error rather than an empty plot:

Section::new("synthetic.device.steady.signals", "AC Voltage").plot(
Plot::time_series("AC Voltage")
.y(SampleRow::voltage_v())
.y(SampleRow::current_a())
.marker_records([
marker("synthetic.device.steady.started"),
marker("synthetic.device.steady.completed"),
]),
)

Those handles resolve their channel through the home the row declared with #[dokime::channel]. One measurement shape published on several suite-owned channels declares no home, so it names the channel at the plot instead — SampleRow::voltage_v().on(Channel) — which is the same rule ctx.channel::<SampleRow>().into::<Channel>() applies to pushing those rows, and the same one a sampler of that shape applies with #[dokime::sampler(channel = Channel, …)]. A host channel the runtime owns is a type too: host::System::cpu_load_percent().

The SDK owns the standard start, drain, cancel, and respond routes, including replay, cancellation, prompts, criteria, evidence, and shutdown. The plugin stores only its own resources. An unbound route answers its own typed payload:

src/routes/status.rs
pub struct Status;
#[dokime::route(id = "device.status")]
impl Status {
async fn handle(plugin: &mut Device, request: StatusRequest) -> Result<StatusReply> {
plugin.read_status(request).await
}
}

Each route lives in its own file and the catalog enum lists the types. The SDK emits one complete static inventory for the final sidecar identity from those catalogs: standard execution routes, telemetry start only when a streams catalog is named, and the authored handlers. Co-linked descriptors alone advertise nothing. Inspection runs no initialization or registration code, and refuses binaries without a matching complete inventory. The macro generates a route descriptor, so authors do not implement transport traits or wrap requests in local carrier types.

RecordPublishRequest is the wire shape the drain carries; it is not an authoring type and does not appear in a step, a criterion, a suite or a test. Leave scope and source unset on every TelemetrySample/RecordPublishRequest JSON value pushed into a drain reply. The host overwrites both with the owning artifact plus the scheduled cycle, case, execution, and template scope before it validates the strict contracts from step 2 — a sidecar cannot claim evidence for another artifact or execution.

Report the terminal SidecarExecutionOutcome exactly once, as one drain item; the host stops polling once it observes it. Alongside passed and message it carries criteria, where each declared criterion reports passed, failed, or indeterminate — the third for a criterion the run could not evaluate, which is not the same claim as a measured failure.

Steps act through StepContext: emit records, push samples, ask declared operator prompts, and stream evidence files. The SDK records each declared step as it starts, and a criterion reads that durable evidence back with ctx.steps_reached(), so a step cannot claim a step it did not reach. For bounded work inside the current step, wrap any exact-size iterator with ctx.progress("Measuring samples", samples). The loop advances progress automatically after each body completes and exposes an ETA after three completions. That latest value is process-local operator state, not telemetry or report evidence, and disappears when the execution ends. Every context logs. ctx.trace(...), ctx.debug(...), ctx.info(...), ctx.warn(...) and ctx.error(...) take &self where the rest of the grant takes &mut self, because a log line is a send onto the host’s own queue and a hook handed a shared context — resources, traceability — has as much to say about what it is doing as a step does. They exist on CriterionContext too, where the line is attributed to the criterion that wrote it, and on the plugin Context handed to initialize, where they go to the plugin’s own diagnostic stream: there is no run behind that one, so the host mirrors it on the sidecar tracing target under the plugin’s id, before the readiness gate. Their severity survives cursor replay and is stored as a level prefix in the host-owned {workflow}/stream.log. The host also mirrors them to tracing at the matching level; tracing filters do not control execution-log capture. Logging an error does not change the test outcome. Every runnable template defines an async initialize constructor taking its declared parameters and &mut InitializationContext and returning Result<Self, Error> — a template that names no parameters takes only the context. It builds the attempt’s data from those inputs, the settings and the secrets — never anything ephemeral; static catalog inspection never calls it. The SDK does not construct templates with Default, and a template does not implement Clone. Criteria are associated functions taking only read-only CriterionContext; streams observe through the attempt’s own handles and the tick they were asked on. The host durably stores every artifact and emits artifact.created with its own artifact id.

ctx.artifacts() — available from InitializationContext, StepContext, and CleanupContext — hands evidence files to the host as they are produced. There is no staging directory: bytes become bounded pieces of the execution protocol as they arrive, so a large file never exists twice and a long encode starts reaching the host while the step is still running.

use std::io::Write;
// One call, for content the step already holds.
ctx.artifacts().write("evidence/pulse.csv", bytes)?;
// A writer, for content produced a piece at a time.
let mut trace = ctx.artifacts().create("evidence/trace.csv")?;
writeln!(trace, "sample,value")?;
for (sample, value) in readings {
writeln!(trace, "{sample},{value}")?;
}
trace.finish()?;
// A file some other tool wrote, streamed without reading it into memory.
ctx.artifacts().import(&recorded_path, "evidence/capture.mp4")?;

The name is the relative, /-separated path the artifact is filed under. It must be non-empty and may not be absolute or name a . or .. segment; an invalid name is refused before any piece is sent. The media type is read from the name’s extension, and a name that does not say what the content is — no extension, or one the media type cannot be read from — is refused at the call too. There is no typed override: the name is the artifact’s one statement about itself, and an override is how that statement starts to be a lie. Name opaque bytes .bin, which says exactly that.

ArtifactWriter implements std::io::Write, so any encoder that writes to a sink can write straight into the protocol. finish() sends the artifact’s final piece; dropping an unfinished writer sends it too, so returning early from a step still hands over what was produced. flush() sends whatever is buffered as one piece; chunk boundaries otherwise belong to the writer, and each piece costs a frame in the execution’s bounded output buffer, so flushing after every small write spends that budget without making the artifact arrive sooner.

abandon() is the opposite statement — the content is known to be incomplete — and it says so on the wire rather than going quiet. It sends one terminal piece that carries no content and marks the transfer aborted, dropping bytes the writer still held rather than handing over a little more of a file that is not evidence. The host answers immediately: it releases what it assembled, gives back the open artifact slot and the execution byte budget that transfer had spent, never opens an upload, and writes one WARN line naming the file to the execution’s own log. That release is what lets a run give up on more streams than it may keep open at once and still produce evidence afterwards. An artifact the execution neither finishes nor abandons is discarded too, but only when the run ends, and its WARN line says the transfer never completed rather than that its producer gave up.

The host stores each artifact under a safe basename it derives from the name, so a /-separated name organizes the run’s own output rather than the artifact’s stored identity. It also bounds what one execution may hand over — a size per artifact, a size per execution, and a bounded number of artifacts left open at once — so finish a writer before opening the next one where the work allows it.

A directory, for a producer that never heard of Dokime

Section titled “A directory, for a producer that never heard of Dokime”

The writer above is for a producer that knows Dokime. Plenty do not: a Harmos job, a vendor tool, a script, an instrument’s own recorder. Each writes files where it is told to and hands back nothing but a path, so what it needs handed to it is a directory:

let evidence = ctx.artifacts().directory("capture")?;
recorder.record_into(&evidence).await?; // knows nothing about Dokime
std::fs::write(evidence.join("notes.txt"), summary)?;

The path is the execution’s own, under the sidecar’s temporary working area and keyed by the execution. Everything under it is filed as <name>/<path within the directory> — capture/notes.txt above — on the terms create states: the media type is read off the extension, and each file spends one of the execution’s artifact transfers. Asking twice for one name answers the same path, from resources() through cleanup, so a step and the teardown that follows it write into one place.

Nobody announces a file, so the SDK watches. On the host’s own drain cadence it stats every regular file under every registered directory and sends what changed:

  • A file that grew sends the bytes it grew by, as the next piece of the transfer it already owns — so a log written over a twenty minute soak reaches the host while the soak is running. Growth is coalesced until it is worth a piece: every piece costs a frame in the execution’s bounded output buffer and a replay fingerprint the host keeps for the life of the transfer.
  • A file that shrank, or whose size stayed put while its modification time moved, was rewritten rather than appended to. It is sent again from the start behind a replace marker and the host drops what it had, so the stored artifact is what the file says rather than every version of it end to end.
  • The final sweep runs after cleanup, so evidence written while releasing what the attempt held is evidence like any other. It finalizes every file and then removes the directory.

The rule has one honest blind spot: a rewrite that lands larger between two ticks looks exactly like an append and is sent as one. Telling the two apart would mean re-reading and re-hashing every watched file on every tick, which is a guarantee no evidence file has asked for. A tool that rewrites its output in place is noticed the moment that output is not strictly longer.

A file the watch will not file says so once, by name, as a WARN diagnostic on the execution’s own log, and is skipped from then on: one past the size limit an artifact may carry, and one whose name does not say what it is — a lock file, a .tmp, anything without a readable media type. Symbolic links are neither followed nor filed. A directory is for a producer’s output rather than for a filesystem: its files share the bounded number of transfers one execution may open, and the stored artifacts are indistinguishable from ones handed over through create.

Synthetic embeds a Harmos app for plugin readiness and orderly shutdown. Its Testbench state belongs to the process; the read-only synthetic.status route reports readiness without changing it. Boot the app in initialize():

async fn initialize(_config: Config, _ctx: &dokime::plugin::Context) -> Result<Self> {
let runtime = app::launch(Testbench::default()).await?;
Ok(Self { runtime, /* ... */ })
}

? is enough because Error declares a #[from] variant for the launch failure, which keeps the application’s own reason in the diagnostic chain.

app.rs is the file to read first in an embedded app. It holds the identity struct and its impl harmos::Application, the Origin/Builder/Runtime/ Journal aliases no other module has to spell, and the entry verbs: builder declares the app and registers its catalogs exactly once, launch stands it up live, and simulate — behind the sim feature — stands the same declaration up deterministically on nothing but a seed. Both verbs funnel through builder, so a test exercises the application the sidecar actually runs.

Each execution owns a separate simulated DUT from the reusable app-runtime domain model. Its steps and SDK sampler share the same device handle, so control and fault injection affect the measured voltage and current. Samples do not commit mutations to the process readiness state.

A native plugin declares type Resources in its dokime::Plugin implementation and exports the bundle from the synchronous resources(&self) on that same trait. Use () when it needs no shared resources:

pub struct Resources {
pub app: app_runtime::Client,
}
impl dokime::Plugin for Bench {
type Config = Config;
type Error = Error;
type Resources = Resources;
// …
fn resources(&self) -> Resources {
Resources { app: app_runtime::Client::new(&self.runtime) }
}
}
// Inside a #[dokime::template(…, plugin = Resources, parameters = SoakParameters)] impl:
async fn initialize(
parameters: &SoakParameters,
ctx: &mut dokime::execution::InitializationContext<'_>,
) -> Result<Self, Error> {
ctx.plugin().app.ensure_ready().await?;
Ok(Self { target_c: parameters.target_c })
}
/// The handles this one attempt holds, opened after initialize and dropped
/// after cleanup returns.
async fn resources(
&self,
ctx: &dokime::execution::ResourceContext<'_>,
) -> Result<HeaterResources, Error> {
Ok(HeaterResources {
heater: tokio::sync::Mutex::new(Heater::open(&ctx.parameters().port).await?),
app: ctx.plugin().app.clone(),
})
}

The SDK obtains this bundle once after plugin initialization and shares it across all executions of that instance. Resource types must be Send + Sync + 'static; neither bundle needs to implement Clone. A template names the plugin’s bundle once, on its attribute (plugin = Resources), and every stage grant is then written bare: StepContext<'_>, CleanupContext<'_>. Omitting the attribute means ().

Two handles are in reach and the verb tells them apart. ctx.plugin() is the plugin’s bundle — shareable clients and pools, read by every execution, by every listener, and by every process-wide sampler’s start. ctx.resources() is the attempt’s own, declared by the template’s resources hook: an exclusive port, a file, a child process, a session. The engine holds it behind one Arc for the life of the attempt, hands the same value to every sampler of that template, and drops it once cleanup has returned — so a sampler never needs a clone of the template a step is holding &mut self over, and concurrent access is the author’s business through interior mutability. The plugin still owns runtime shutdown. Cancelling a step does not implicitly cancel detached application jobs; cleanup must cancel and settle any such jobs owned by that execution.

Resources are local Rust values, never transported in settings, serialized into execution snapshots, or recovered from historical evidence. Static catalogs and CriterionContext do not expose them.

Not every handle lives as long as the attempt does. A watch one step starts and another stops, a recording that spans three steps, a job the procedure begins and cleanup has to end because nothing else will — each is one value, absent until something puts it there and taken out exactly once by whoever ends it. dokime::Slot<T> is that place:

pub struct SoakResources {
/// Started by the step that begins the soak, stopped by cleanup: a Harmos
/// job handle does not cancel when it is dropped.
capture: dokime::Slot<app::JobHandle>,
}
// In the step that starts it.
ctx.resources().capture.set(ctx.plugin().app.start_capture().await?);
// In cleanup, however the run ended.
if let Some(capture) = ctx.resources().capture.take() {
capture.cancel().await?;
}

It is a Mutex<Option<T>> with new, set, take and is_set, because resources() is &self everywhere it is reached — a sampler on its own task reads the same value the step does. set answers whatever was there, so a second start hands the first handle back to be dealt with rather than losing it; take empties the slot, so a cleanup that runs after a step already stopped the handle is a no-op rather than a double stop. It tolerates a poisoned lock, on the rule the rest of the SDK follows: a panic in one step must not turn a handle somebody still has to stop into a handle nobody can reach.

Keep the harmos::Runtime<P> handle on Synthetic. The SDK joins samplers and runs execution cleanup before the plugin’s own cleanup. Each template’s cleanup powers down its DUT through CleanupContext, which exposes captured inputs, secrets, logging and reason() (Completed, Failed, or Cancelled). A final normal step publishes measurement decisions before artifact work; cleanup releases resources after all samplers have joined and logs diagnostics. Criteria read captured evidence after the attempt’s handles are released, so decisions must be recorded during steps instead of read from live device state. Cleanup runs once for every template whose resources opened, including failed or cancelled procedures. A failure in initialize or resources has nothing open to release; the constructor must release its own partial acquisitions. The plugin’s cleanup(self) then commits Stop to the app and awaits runtime shutdown, taking the plugin by value because it is the last holder.

A template says what it is testing through one more hook, run after resources and before the first step so it can read a serial number or a firmware revision off the device it describes:

use dokime::traceability::{TraceabilityRow, TraceabilitySection};
async fn traceability(
&self,
ctx: &dokime::execution::TraceabilityContext<'_>,
) -> Result<Vec<TraceabilitySection>, Error> {
let identity = ctx.resources().heater.lock().await.identity().await?;
Ok(vec![
TraceabilitySection::new("dut", "Device Under Test")
.row(TraceabilityRow::text("serial_number", "Serial number", identity.serial))
.row(TraceabilityRow::text("firmware", "Firmware", identity.firmware)),
])
}

Sections travel the drain, and the host stamps their scope, their time and the provenance they are filed under. A failure here fails the attempt before its first step: traceability that cannot be established is not evidence to run on. The Traceability card renders each section with the label and rows the template wrote, and the report renders them as tables — so identity never has to be smuggled through records or record= log lines for a card to reassemble.

The host’s generic sidecar executor polls drain on a fixed cadence (every 100ms in the shipped executor) between calls. Drained samples and records publish through the ordinary telemetry/record path with execution scope now stamped; the step the run has reached mirrors onto the executing case. Plot cards over the suite’s telemetry channel render those samples live in the one ordered document, for both the single execution and the owning cycle.

A leased global channel is not the execution’s own output: its rows are produced once by whoever owns the channel — the host’s sampler, or a sidecar’s process-wide stream — and captured by the host into every execution holding a lease, and they render in the same telemetry views. What the run held is written down beside them, as a derived Leases traceability section naming each channel, its owner, the window the attempt held it for and who else was reading the same rows. A channel whose owner restarted while the attempt held it carries a gap row there: rows are missing for that interval, the run continues, and the section is what says the run did nothing wrong.

A template is the part of a plugin worth a test on its own: it reads parameters, opens handles, states what it is testing, measures and decides. None of that needs a host — it needs the things a host would have supplied. dokime::plugin::testing::Harness is those things as builder calls.

use dokime::plugin::testing::Harness;
use dokime::host::ToolConnectionAcquired;
#[tokio::test]
async fn the_soak_holds_in_band() {
let report = Harness::template("thermo.soak.hold")
.parameter("hold_samples", 3)
.setting("sensor_port", "sim")
.secret("cloud_token", "sim-token")
.publish(ToolConnectionAcquired {
resource_id: "sim-1".into(),
resource_kind: "chamber".into(),
connection_transport: None,
connection_endpoint: None,
status_message: None,
})
.answer("thermo.soak.checkpoint", "continue", [("note", "chamber steady")])
.run::<thermo_bench::Plugin>(thermo_bench::Config::default())
.await
.expect("the soak runs");
assert!(report.passed(), "{}", report.diagnostics());
assert_eq!(report.traceability("dut").row("firmware").text(), "sim-1.0");
assert_eq!(report.samples::<thermo_bench::suites::soak::SoakRow>().len(), 3);
}

The harness plays the host and plays it the way the host does. .parameter names one value of the case and the rest are resolved from the template’s own declared defaults, exactly as admission resolves them. .setting and .secret are admitted over the plugin’s declared catalog. .publish files a record with the harness’s own provenance stamped on it, because a party naming its own provenance is a party that can lie about it — which is also what lets a step waiting on that id be answered by it. .answer decides one operator question by the prompt type the template declared; a prompt no test answers is continued from the template’s own declaration, so an unattended run is not a hung one.

Report reads back in the vocabulary a criterion reads evidence in: passed(), criteria(), samples::<C>() as the shape that channel declared — the same read ctx.samples::<C>() hands a criterion — records::<R>() decoded through the same declaration the run published through, traceability(section).row(id).text(), artifacts() reassembled from the pieces they crossed the protocol in, and diagnostics() — the failure’s message, cause chain, location, span trace and backtrace, plus the execution log, which is what a criterion that decided against the run leaves behind instead.

A run that fails is a report whose verdict is false, not an error: an authored failure is evidence, and the test was written to read it. HarnessError is for a run that could not be made to happen at all — a template no suite declares, a case that will not resolve, a plugin that refused to initialize, a run that outlasted .timeout(d).

dokime::plugin::sidecar::Instance stays underneath and public. Drive it directly for a test about the wire itself — replay, cancellation, an unknown token — because those are questions about the protocol rather than about a template.

Terminal window
mise run check:rust
mise run build:plugins:dev
mise run smoke:runtime

See Installed Artifact Workflow for how the suite joins the rest of the artifact domains and Installed Artifact for the shipped default composition.

Dokime markDokime · Stokker Technologies