VOLT
Plugins

Plugin System

How analysis workflows, binaries, exposures, and exports work — build a plugin node by node.

Plugin workflow structure

Plugin workflows are built in the UI at https://app.voltcloud.dev/dashboard/plugins/list.

A plugin starts with Modifier, Arguments, Context, ForEach, and Entrypoint. One Exposure node is added for each result file VOLT should ingest. To turn an exposure into a 3D artifact, an Export node is connected to it.

NodeResponsibility
ModifierDefines how the plugin appears in the UI
ArgumentsDefines the parameters the user configures before execution
Context + ForEachDefine which trajectory data is iterated
EntrypointDefines the runtime and the uploaded payload to execute
ExposureDefines which output file VOLT reads after execution
ExportDefines how VOLT interprets geometry or chart data from an exposure

What a plugin looks like inside

A complete example plugin is at atomistic-exporter-clusters.zip — an importable plugin with its plugin.json and the binary ZIP the Entrypoint node expects.

The script reads atom positions from the current dump and groups them into Cluster 0, Cluster 1, and Cluster 2 — the keys VOLT uses to color atoms in the viewer. The same exposure exports a per-atom property named cluster_id. The grouping rule (atom_id % 3) is a placeholder to demonstrate the output format; replace it with real clustering logic.

Atomistic exporter example workflow

The script runs in four phases: read the dump and find the ITEM: ATOMS section, validate the required columns exist, assign each atom to an example cluster, and build one Apache Parquet payload containing listings, per-atom-properties, and the grouped AtomisticExporter data.

import parquet
import sys

# From "arguments" in Entrypoint node configuration.
input_dump_path = sys.argv[1]
output_base = sys.argv[2]

# Read the current trajectory dump and find the ATOMS section.
with open(input_dump_path) as f:
    lines = f.read().splitlines()

atoms_header_idx = next(
    idx for idx, line in enumerate(lines)
    if line.startswith('ITEM: ATOMS')
)

# The AtomisticExporter needs atom id + position.
atom_columns = lines[atoms_header_idx].split()[2:]

id_idx = atom_columns.index('id')
x_idx = atom_columns.index('x')
y_idx = atom_columns.index('y')
z_idx = atom_columns.index('z')

# This is only an example grouping strategy.
# Replace atom_id % cluster_count with your real cluster assignment logic.
cluster_count = 3
cluster_labels = [f'Cluster {index}' for index in range(cluster_count)]
grouped_atoms = {label: [] for label in cluster_labels}
per_atom_properties = []

# Build both outputs at the same time:
# 1. grouped atoms for AtomisticExporter
# 2. per-atom properties so cluster_id is also available in VOLT tables/filters
for raw_line in lines[atoms_header_idx + 1:]:
    values = raw_line.split()
    atom_id = int(values[id_idx])
    position = [
        float(values[x_idx]),
        float(values[y_idx]),
        float(values[z_idx])
    ]

    # cluster_id is the numeric property, cluster_label is the export group name.
    cluster_id = atom_id % cluster_count
    cluster_label = cluster_labels[cluster_id]
    grouped_atoms[cluster_label].append({
        'id': atom_id,
        'pos': position
    })
    per_atom_properties.append({
        'id': atom_id,
        'cluster_id': cluster_id
    })

export_groups = {
    label: atoms
    for label, atoms in grouped_atoms.items()
    if atoms
}

# sub-listing
cluster_rows = [
    {
        'cluster': label,
        'atoms': len(atoms)
    }
    for label, atoms in export_groups.items()
]

# One exposure file can include listings, per-atom properties, and 3D export data.
payload = {
    'main_listing': {
        'cluster_count': len(export_groups),
        'exported_atoms': sum(len(atoms) for atoms in export_groups.values())
    },
    'sub_listings': {
        'clusters': cluster_rows
    },
    'per-atom-properties': per_atom_properties,
    'export': {
        'AtomisticExporter': export_groups
    }
}

# The Exposure node expects: {output_base}_example.parquet
with open(f'{output_base}_example.parquet', 'wb') as f:
    f.write(parquet.packb(payload, use_bin_type=True))

print(f'Wrote {output_base}_example.parquet with {len(export_groups)} atom groups')

Atomistic exporter canvas example

Atomistic exporter workflow explanation

Atomistic exporter workflow explanation 2

All listings from plugin analyses are also visible in the dashboard, via the sidebar path Analysis > Plugin Name > Exposure Name.

Atomistic exporter workflow data dashboard

The same coloring is reproducible without AtomisticExporter groups: color coding on the exported cluster_id property produces the same cluster-based coloring.

Atomistic exporter color coding comparison

A scene can hold multiple models at once, to compare exported artifacts or add plugin-contributed models on top of the base trajectory.

Multiple models in scene

Plugin right-click menu

The right-click menu manages an existing plugin.

Plugin right click menu

ActionEffect
EditOpen the plugin builder and modify the workflow
CloneCreate a copy of the plugin
ExportExport the plugin for reuse or import elsewhere
PublishMake the plugin available in the canvas
Set as DraftReturn the plugin to draft state and hide it from the canvas
DeleteRemove the plugin

A new plugin must be published to appear in the canvas. Plugins in draft state remain hidden.

Node-by-node configuration

Modifier

The Modifier node defines how the plugin appears in VOLT.

FieldExample valueWhat it does
NameHello World PluginPlugin name shown in the UI
Version1.0.0Visible plugin version
DescriptionPrints execution information to the logShort plugin summary
AuthorVolt LabsPlugin author
IconTbPlugConnectedIcon used in the plugin card/editor

Modifier node configuration

Arguments

The Arguments node defines the parameters that the program receives at runtime.

If your Entrypoint arguments template contains {{ plugin-arguments.as_str }}, VOLT serializes the configured arguments and passes them to your program. For Python plugins, those values arrive in sys.argv together with the input path and the output base path.

FieldExample valueWhat it does
ArgumentcutoffCLI parameter name
Typenumber, boolean, string, select, listInput type shown in the UI
LabelCutoffUser-facing label
Default Value3.25Default runtime value
Min / Max / Step0 / 10 / 0.05Numeric constraints
OptionsFCC, HCP, BCCOptions for select-like inputs

Arguments node configuration

Context

The Context node defines where the workflow gets its runtime data from. Most analysis workflows use trajectory_dumps.

FieldExample valueWhat it does
Sourcetrajectory_dumpsUses the trajectory frames generated by VOLT

Context node configuration

ForEach

The ForEach node defines how the workflow iterates over the selected context. This is what makes most plugins run frame by frame.

FieldExample valueWhat it does
Iterable Source{{ trajectory-context.trajectory_dumps }}Iterates through the trajectory dumps one by one

ForEach node configuration

The autocomplete appears while typing {{ tra... }}; the trajectory_dumps value comes from the Context node and is referenced directly from ForEach.

ForEach trajectory dumps autocomplete

Entrypoint

The Entrypoint node defines how the uploaded payload is executed, and whether the payload is a Python ZIP, a single executable, or a packaged executable with extra runtime files.

FieldExample valueWhat it does
Typepython-scriptChooses the runtime mode
Binary / Packagelisting-example.zipUploaded file to execute
Entry Scriptmain.pyScript or executable inside the uploaded package
Requirements FileparquetPython dependencies to install
Arguments{{ foreach-trajectory-dumps.currentValue.path }} {{ foreach-trajectory-dumps.outputPath }} {{ plugin-arguments.as_str }}Runtime argument template
Timeout-1Optional execution timeout

Binary entrypoint node configuration

Python script entrypoint node configuration

When Type is python-script, VOLT enables a field for the PyPI dependencies the script needs; they go in Requirements File and are installed before execution.

Exposure

The Exposure node tells VOLT which output file should be ingested after execution.

FieldExample valueWhat it does
Exposure NameStructure IdentificationThe label shown in VOLT for that result
Results File Suffixatoms.parquetThe exact suffix the plugin writes after the output base (e.g. atoms.parquet, defect_mesh.parquet, dislocations.parquet)
IconTbEyeOptional result icon

File naming contract. Given an output base of abc and a Results File Suffix of atoms.parquet, the plugin must write abc_atoms.parquet. VOLT will not ingest the result if the filename does not match, even when the payload is valid.

import parquet
import sys

output_base = sys.argv[2]

payload = {
    "main_listing": {
        "identified_atoms": 9211,
        "defect_atoms": 314
    }
}

with open(f"{output_base}_atoms.parquet", "wb") as f:
    f.write(parquet.packb(payload, use_bin_type=True))

If output_base is abc, this writes abc_atoms.parquet, which matches the Exposure node contract.

One Entrypoint can connect to several Exposure nodes: a single execution can produce multiple result files, each with its own exposure.

Entrypoint with many exposures

Pair each Exposure with its own Export node when different result files from the same entrypoint need different exporters.

Exposure and export configuration examples

Export

The Export node tells VOLT how to convert the export payload from an exposure into an artifact.

FieldExample valueWhat it does
ExporterAtomisticExporterChooses the exporter implementation
TypeglbDefines the artifact type
Options{ "smoothIterations": 8 }Exporter-specific options

An Export node requires a connected Exposure node: the exporter reads the export key from the exposure result file.

Choosing the right Entrypoint type

The Type field in the Entrypoint node selects the runtime model.

Executable

Choose Executable when you already have a single runnable binary.

  • Upload a binary file directly.
  • You do not set Entry Script.
  • You do not use Requirements File.
  • The daemon runs that binary as-is.

Python Script

Choose Python Script when your plugin is Python code packaged as a ZIP project.

  • Upload a ZIP file that contains your Python project.
  • Set Entry Script to the Python file inside that ZIP, for example main.py or scripts/cna_plugin_wrapper.py.
  • Paste any Python dependencies into Requirements File, or leave it empty if there are none.

Packaged Executable

Choose Packaged Executable when your runtime is a ZIP bundle that contains an executable plus supporting files such as bin/, lib/, lookup tables, or other resources.

  • Upload a ZIP file.
  • Set Entry Script to the executable or launcher inside the archive.
  • Do not use Requirements File.

At runtime, team clusters extracts the ZIP and resolves the executable path from Entry Script before running it. A typical case is a packaged scientific binary with shared libraries, such as the native OpenDXA executable bundle.

A practical rule

If your payload is...Entrypoint type
one compiled binary fileExecutable
a Python project in a ZIPPython Script
a ZIP with an executable plus lib/, bin/, or other runtime filesPackaged Executable

What to put in Arguments

The Arguments field is the command template passed to the selected runtime. The arguments delivered to the entrypoint follow a common pattern:

  • input dump path,
  • output base path,
  • optional serialized parameters.
VariableResolves to
{{ foreach-trajectory-dumps.currentValue.path }}The current input dump path
{{ foreach-trajectory-dumps.outputPath }}The output base path used by exposures
{{ plugin-arguments.as_str }}All UI-configured plugin arguments serialized as CLI flags
{{ <entrypoint-node-id>.projectPath }}The extracted package directory (for packaged executables)

A Python ZIP typically uses:

{{ foreach-trajectory-dumps.currentValue.path }} {{ foreach-trajectory-dumps.outputPath }} {{ plugin-arguments.as_str }}

A packaged executable typically uses:

{{ foreach-trajectory-dumps.outputPath }}_annotated.dump {{ foreach-trajectory-dumps.outputPath }} --lattice_dir {{ analysis-entrypoint.projectPath }}/share/volt/lattices {{ plugin-arguments.as_str }}

The script reads the input dump from sys.argv[1], the output base from sys.argv[2], and writes a parquet file whose name matches the exposure suffix:

import sys
import parquet

input_file = sys.argv[1]
output_base = sys.argv[2]

payload = {
    "main_listing": {
        "ok": True
    }
}

with open(f"{output_base}_results.parquet", "wb") as f:
    f.write(parquet.packb(payload, use_bin_type=True))

When the exposure suffix is results.parquet, this file is ingested correctly.

The upload button in the Entrypoint editor is enabled only after the plugin has been saved at least once. Save the workflow before uploading the binary or ZIP.

What can go inside one exposure file

An exposure file is an Apache Parquet object containing one or more of these keys:

{
  "main_listing": {},
  "sub_listings": {},
  "per-atom-properties": [],
  "export": {}
}
KeyUse it for
main_listingSmall summary values
sub_listingsTables with many rows
per-atom-propertiesValues attached to atoms by id
exportData consumed by AtomisticExporter, MeshExporter, LineExporter, or ChartExporter

One exposure can contain only one of these keys, or several at once.

Returning listings

To show summary values and result tables, return main_listing and sub_listings.

payload = {
    "main_listing": {
        "total_points": 1145,
        "dislocations": 319,
        "total_length": 2825.21
    },
    "sub_listings": {
        "dislocation_segments": [
            {
                "segment_id": 0,
                "length": 11.45,
                "magnitude": 0.408,
                "burgers_vector": [-0.16, -0.16, -0.33]
            }
        ]
    }
}

Returning per-atom-properties

Use per-atom-properties when you want VOLT to attach analysis values back to atoms. Each row must identify the atom with id, which links the row back to that atom.

Row format

{
  "per-atom-properties": [
    {
      "id": 1,
      "csp": 0.042,
      "strain": [0.10, 0.02, -0.01],
      "structure_type": 2
    },
    {
      "id": 2,
      "csp": 0.731,
      "strain": [0.18, 0.05, 0.00],
      "structure_type": 0
    }
  ]
}

Columnar format

VOLT also accepts a columnar shape:

{
  "per-atom-properties": {
    "id": [1, 2],
    "csp": [0.042, 0.731],
    "strain": [
      [0.10, 0.02, -0.01],
      [0.18, 0.05, 0.00]
    ],
    "structure_type": [2, 0]
  }
}

Important details:

  • Scalar values work as-is.
  • Array values also work. VOLT flattens them into fields such as strain[0], strain[1], and strain[2] when needed.
  • Use this key to let users filter atoms, color by a numeric property, or inspect analysis values in the particles table.

Using AtomisticExporter

Use AtomisticExporter when your result is a set of atoms or points that should come back into the viewer as a GLB artifact.

In the Export node:

  • choose AtomisticExporter,
  • choose export type glb,
  • connect that export node to the exposure that will contain the atom payload.

Then write the export payload inside the same Apache Parquet file under export.

Single-object export format

{
  "export": {
    "AtomisticExporter": {
      "FCC": [
        { "id": 1, "pos": [0.0, 0.0, 0.0] },
        { "id": 2, "pos": [0.5, 0.5, 0.0] }
      ],
      "HCP": [
        { "id": 3, "pos": [1.0, 0.0, 0.0] }
      ],
      "Other": [
        { "id": 4, "pos": [1.5, 0.5, 0.0] }
      ]
    }
  }
}

Array export format

{
  "export": [
    {
      "AtomisticExporter": {
        "FCC": [
          { "id": 1, "pos": [0.0, 0.0, 0.0] }
        ]
      }
    },
    {
      "AtomisticExporter": {
        "Defects": [
          { "id": 9, "pos": [2.0, 0.0, 0.0] }
        ]
      }
    }
  ]
}

Important details:

  • VOLT accepts both envelopes: one exporter object, or an array of exporter objects.
  • In the single-object form, the payload is a grouped object where each key is a group name, and those names drive the colouring in the viewer.
  • Names like FCC, BCC, HCP, Other, or Cluster 7 map directly to existing palettes.
  • The pos field is required for each atom.
  • The array form produces one artifact per array entry.

This is the exporter used by Structure Identification and coherent crystalline region overlays.

Combining multiple result types in one exposure

A single exposure can return:

  • a summary in main_listing,
  • atom-attached values in per-atom-properties,
  • and a 3D overlay in export.
{
  "main_listing": {
    "identified_atoms": 9211,
    "defect_atoms": 314
  },
  "per-atom-properties": [
    { "id": 1, "structure_type": 2 },
    { "id": 2, "structure_type": 0 }
  ],
  "export": {
    "AtomisticExporter": {
      "FCC": [
        { "id": 1, "pos": [0, 0, 0] }
      ],
      "Other": [
        { "id": 2, "pos": [1, 0, 0] }
      ]
    }
  }
}

Downloadable example plugins

Most of the ZIPs below are small entrypoint examples that contain only the plugin code; you still configure the workflow itself from the UI with nodes.

hello-world-plugin

  • Download: hello-world-plugin.zip
  • This is the smallest possible example of an Entrypoint node.
  • Shows that VOLT executes your script and that anything you print(...) appears in the execution log.
  • Does not write an exposure file. Useful for verifying entrypoint execution before adding Exposure outputs.

Flow in the builder

Modifier -> Arguments -> Context -> ForEach -> Entrypoint

The Arguments node can stay empty for this example.

Hello World workflow

The Entrypoint node is where you upload the ZIP and tell VOLT how to execute the Python script inside it.

Hello World entrypoint configuration

import sys

print('Hello world VOLT!')

# VOLT first-argument correspond to the input file.
input_file = sys.argv[1]

print(f'Input file: {input_file}')

The execution log is the expected result: the print(...) output appears after the Entrypoint node runs.

Hello World execution log

arguments-example

  • Download: arguments-example.zip
  • Demonstrates what the Entrypoint node passes into the script through sys.argv.
  • Prints the runtime arguments to the execution log for inspection.

Flow in the builder

Modifier -> Arguments -> Context -> ForEach -> Entrypoint

The image below shows the full example: user-facing arguments at the top, the workflow in the center, and the Arguments node configuration at the bottom.

Arguments plugin example

import sys

print('sys.argv:')
for idx, arg in enumerate(sys.argv):
    print(idx, arg)

The execution log lists the received arguments one by one.

Arguments plugin example log

listing-example

  • Download: listing-example.zip
  • Shows how an Exposure node returns main_listing and sub_listings — the result shape VOLT reads to render summary values and result tables.
  • If the Exposure node uses example.parquet as its results suffix, this script writes the correct file: {outputBase}_example.parquet.
  • Once the run finishes, the output is visible through the exposure result in VOLT rather than only in the execution log.

Flow in the builder

Modifier -> Arguments -> Context -> ForEach -> Entrypoint -> Exposure

The key node is Exposure: it tells VOLT to ingest the Apache Parquet file written by the program and render the listing output in the UI.

Listing Example workflow

The Exposure node configuration is where the results file suffix is defined, so it must match the filename written by the Python code.

Listing Example exposure configuration

import parquet
import sys

# Each key in main_listing and sub_listings becomes a column name in VOLT.
payload = {
    'main_listing': {
        'average_segment_length': 8.856480893829213,
        'max_segment_length': 15.068283281607219,
        'min_segment_length': 0.0004022584697172903,
        'total_length': 2825.2174051315187,
        'total_points': 1145,
        'dislocations': 319
    },
    'sub_listings': {
        'circuit_information': [
            {
                'average_edge_count': 5.155642023346304,
                'dangling_circuits': 0,
                'total_circuits': 514
            }
        ],
        'dislocation_segments': [
            {
                'burgers_vector': [-0.16666666666666669, -0.16666666666666666, -0.3333333333333333],
                'length': 11.45334956118728,
                'magnitude': 0.408248290463863,
                'segment_id': 0
            },
            {
                'burgers_vector': [-0.3333333333333333, 0.16666666666666652, 0.16666666666666674],
                'length': 10.363263816655667,
                'magnitude': 0.40824829046386296,
                'segment_id': 1
            }
        ]
    }
}

output_base = sys.argv[2]
with open(f'{output_base}_example.parquet', 'wb') as f:
    f.write(parquet.packb(payload, use_bin_type=True))

print(f'Wrote {output_base}_example.parquet')

Running plugins in the canvas

Once the plugin is created and published, it becomes available in the trajectory canvas.

Two runtime inputs appear by default when a plugin is selected in the canvas:

  • Cluster: the cluster where the plugin will run.
  • Selected timesteps: the timesteps that will be executed.

Canvas plugin arguments

When testing a plugin on a trajectory, select a specific timestep first. Leaving the execution on the default full range will fail across all timesteps if the plugin is misconfigured.

When a plugin starts executing, its status is updated in real time:

  • queue
  • running
  • success

An executed plugin exposes a right-click menu in the canvas with the following actions:

  • Select
  • Download
  • Delete

Executed plugin right click menu

When a plugin has execution output, the canvas timeline enables a new tab named Log, showing the execution output for the currently selected timestep.

Canvas plugin log

If the plugin returns an exposure with main_listing, the canvas also enables a new tab named after that exposure. In the listing-example, that tab corresponds to the exposure configured earlier in the workflow. Because the example uses hardcoded values, every timestep shows the same main_listing rows.

Canvas plugin example listing

Each main_listing row also has its own right-click menu, with these actions:

  • View inspect atoms
  • Delete
  • View <sub_listing_name> for each exported sub-listing

In this example, the code exports two sub-listings:

  • circuit_information
  • dislocation_segments

Canvas plugin main listing row right click menu

The last image shows one of those sub-listings, circuit_information, for the selected timestep.

Canvas plugin sub listing example

per-atom-properties

  • Download: per-atom-properties.zip
  • Shows how to return per-atom-properties from an Exposure node, attaching derived values back to atoms by id.
  • Those values can then be used in the particles table, filters, and color coding workflows.
  • It does not create a GLB by itself; it teaches the per-atom data contract, not a 3D exporter.

Flow in the builder

Modifier -> Arguments -> Context -> ForEach -> Entrypoint -> Exposure

Same flow as the listing example, but the payload returns per-atom-properties keyed by atom id instead of main_listing / sub_listings.

import math
import parquet
import sys

with open(sys.argv[1]) as f:
    lines = f.read().splitlines()

cols_idx = next(
    idx for idx, line in enumerate(lines)
    if line.startswith('ITEM: ATOMS')
)

cols = lines[cols_idx].split()[2:]
id_idx = cols.index('id')
x_idx = cols.index('x') if 'x' in cols else None
y_idx = cols.index('y') if 'y' in cols else None
z_idx = cols.index('z') if 'z' in cols else None

rows = []
for raw_line in lines[cols_idx + 1:]:
    values = raw_line.split()
    atom_id = int(values[id_idx])

    position = None
    if x_idx is not None and y_idx is not None and z_idx is not None:
        position = [
            float(values[x_idx]),
            float(values[y_idx]),
            float(values[z_idx])
        ]

    rows.append({
        'id': atom_id,
        'structure_type': atom_id % 4,
        'coordination': 12 if atom_id % 2 == 0 else 11,
        'distance_from_origin': math.sqrt(sum(component * component for component in position)) if position else 0.0,
        'position_copy': position or [0.0, 0.0, 0.0]
    })

payload = {
    'per-atom-properties': rows
}

output_base = sys.argv[2]
with open(f'{output_base}_example.parquet', 'wb') as f:
    f.write(parquet.packb(payload, use_bin_type=True))

print(f'Wrote {output_base}_example.parquet')

On this page