Build a working plugin from scratch
This tutorial builds a real domain plugin. Given angular frequency ω and damping rate γ, it computes the quality factor:
Q = ω / (2γ)
The plugin returns a quality_factor metric, writes a JSON analysis artifact, and includes a deterministic Reasoner that needs no API key, so the whole path can be run immediately.
The completed source is in examples/plugins/oscillator_quality, with a run configuration at examples/plugin_tutorial.yaml. Build it yourself first, then compare with the finished version.
1. Prepare the development environment
Python 3.11 or newer is required. From the repository root:
python -m venv .venv
. .venv/bin/activate
python -m pip install -e '.[dev]'
Create the package:
mkdir -p examples/plugins/oscillator_quality/oscillator_quality
touch examples/plugins/oscillator_quality/oscillator_quality/__init__.py
The final tree is:
examples/plugins/oscillator_quality/
├── plugin.yaml
└── oscillator_quality/
├── __init__.py
└── plugin.py
A ZIP may contain one outer directory because the Registry searches for the manifest, but the package must contain exactly one supported manifest: plugin.yaml, plugin.yml, or plugin.json. This tutorial consistently uses plugin.yaml.
2. Write the manifest
Create examples/plugins/oscillator_quality/plugin.yaml:
name: tutorial.oscillator
display_name: Oscillator Quality Tutorial
version: 0.1.0
type: domain
description: A zero-dependency damped-oscillator quality-factor example.
category: domain
domain: classical_mechanics
sdk_version: "1"
entrypoint: oscillator_quality.plugin:OscillatorQualityPlugin
produced_artifacts:
- OscillatorAssessment
capabilities:
- name: tutorial.oscillator.evaluate
description: Calculate the quality factor of a damped oscillator.
input_schema:
angular_frequency: number
damping_rate: number
required_inputs:
- angular_frequency
- damping_rate
output_schema:
metrics: object
observations: object
artifacts: array
output_summary: A quality-factor metric and a JSON assessment artifact.
metric_outputs:
- quality_factor
example:
angular_frequency: 4.0
damping_rate: 0.5
provides:
- scientific_evaluation
produces_artifact_kinds:
- oscillator_assessment
scientific_roles:
- numerical_simulation
This defines three stable identifiers:
- plugin ID:
tutorial.oscillator; - Python entry point:
oscillator_quality.plugin:OscillatorQualityPlugin; - capability ID:
tutorial.oscillator.evaluate.
metric_outputs matters. When a research contract is created, the Runtime uses it to prove that quality_factor can be produced by an authorized capability. example helps the Reasoner use exact field names.
3. Implement the plugin
Leave __init__.py empty. Create oscillator_quality/plugin.py:
from __future__ import annotations
import json
from scientific_agent.context import ResearchContext
from scientific_agent.models import ActionType, ArtifactType, JsonObject, ResearchAction
from scientific_agent.plugins.sdk import (
CapabilitySpec,
PluginArtifact,
PluginExecutionContext,
PluginManifest,
PluginResult,
ScientificPlugin,
)
from scientific_agent.reasoning import Reasoner
CAPABILITY = "tutorial.oscillator.evaluate"
class OscillatorTutorialReasoner(Reasoner):
"""Make the tutorial runnable without an API key."""
async def propose_action(self, context: ResearchContext) -> ResearchAction:
completed = [
experiment
for experiment in context.recent_experiments
if experiment.status == "COMPLETED"
]
if not completed:
return ResearchAction(
action_type=ActionType.RUN_EXPERIMENT,
purpose="Measure the quality factor for the declared oscillator",
reasoning_summary=(
"No oscillator assessment has been recorded, so evaluate the reference case."
),
capability=CAPABILITY,
inputs={"angular_frequency": 4.0, "damping_rate": 0.5},
expected_observation="A positive quality factor with a reusable JSON record.",
success_criteria={"quality_factor_at_least": 3.0},
estimated_cost={"experiment": 1},
)
return ResearchAction(
action_type=ActionType.DONE,
purpose="Conclude the oscillator assessment",
reasoning_summary="The requested oscillator assessment has been recorded.",
)
class OscillatorQualityPlugin(ScientificPlugin):
@property
def manifest(self) -> PluginManifest:
# plugin.yaml is authoritative when loaded from disk. This declaration
# keeps direct SDK use and unit tests self-contained.
return PluginManifest(
name="tutorial.oscillator",
version="0.1.0",
plugin_type="domain",
display_name="Oscillator Quality Tutorial",
description="A zero-dependency damped-oscillator quality-factor example.",
category_type="domain",
domain="classical_mechanics",
entrypoint="oscillator_quality.plugin:OscillatorQualityPlugin",
produced_artifacts=("OscillatorAssessment",),
capabilities=(
CapabilitySpec(
name=CAPABILITY,
description="Calculate the quality factor of a damped oscillator.",
input_schema={
"angular_frequency": "number",
"damping_rate": "number",
},
required_inputs=("angular_frequency", "damping_rate"),
output_schema={
"metrics": "object",
"observations": "object",
"artifacts": "array",
},
output_summary=(
"A quality-factor metric and a JSON assessment artifact."
),
metric_outputs=("quality_factor",),
example={"angular_frequency": 4.0, "damping_rate": 0.5},
provides=("scientific_evaluation",),
produces_artifact_kinds=("oscillator_assessment",),
scientific_roles=("numerical_simulation",),
),
),
)
def create_reasoner(self) -> Reasoner:
return OscillatorTutorialReasoner()
async def execute(
self,
capability: str,
inputs: JsonObject,
context: PluginExecutionContext,
) -> PluginResult:
if capability != CAPABILITY:
return PluginResult(
success=False,
error=f"unsupported capability: {capability}",
)
angular_frequency = float(inputs["angular_frequency"])
damping_rate = float(inputs["damping_rate"])
if angular_frequency <= 0:
return PluginResult(
success=False,
error="angular_frequency must be positive",
)
if damping_rate <= 0:
return PluginResult(
success=False,
error="damping_rate must be positive",
)
quality_factor = angular_frequency / (2.0 * damping_rate)
assessment = {
"angular_frequency": angular_frequency,
"damping_rate": damping_rate,
"quality_factor": quality_factor,
"regime": "underdamped" if quality_factor > 0.5 else "strongly_damped",
"run_id": context.run_id,
"action_id": context.action_id,
}
return PluginResult(
success=True,
metrics={"quality_factor": quality_factor},
observations={
"quality_factor": quality_factor,
"regime": assessment["regime"],
},
artifacts=(
PluginArtifact.text(
name="oscillator-assessment.json",
artifact_type=ArtifactType.ANALYSIS_RESULT,
content=json.dumps(assessment, indent=2, sort_keys=True),
metadata={
"format": "json",
"method": "Q = angular_frequency / (2 * damping_rate)",
},
),
),
)
What the code does
execute() is the main plugin boundary. The Runtime has already checked field shape against the manifest, then supplies the capability name, ordinary JSON inputs, and execution context. The plugin must still enforce domain rules such as positive frequency and damping.
For an expected input failure, return PluginResult(success=False, error="...") rather than throwing an exception. On success:
metricscontains only finite comparable numbers;observationscontains a JSON-serializable summary;artifactscontains the durable evidence;context.run_idandaction_idmay be recorded as provenance, but the plugin must not guess Runtime storage paths.
The Python manifest makes direct SDK use and unit tests self-contained. When loaded from a directory, plugin.yaml is authoritative; keep both declarations aligned.
The deterministic Reasoner exists only to make this tutorial one-command runnable. Most production domain plugins omit create_reasoner() and let the configured LLM Reasoner schedule calls from the capability description and example.
4. Write the run configuration
Create examples/plugin_tutorial.yaml:
title: "Oscillator plugin tutorial"
goal:
description: "Confirm that the reference damped oscillator has quality factor >= 3.0"
acceptance:
metric: quality_factor
operator: ">="
value: 3.0
budget:
max_actions: 3
max_experiments: 1
max_failures: 1
plugins:
- examples/plugins/oscillator_quality
capabilities:
- tutorial.oscillator.evaluate
research_questions:
- "What is the quality factor of the reference oscillator?"
Plugin paths are resolved relative to the current directory where the command is run. Run this example from the repository root.
5. Run the end-to-end test
scientific-agent \
--data-dir /tmp/scientific-agent-plugin-demo \
run examples/plugin_tutorial.yaml
The end should resemble:
[Metric] quality_factor = 4
[CriteriaEngine] Acceptance criteria satisfied
DONE run_id=run_... status=SUCCESS
Retain the run ID and inspect all events:
scientific-agent \
--data-dir /tmp/scientific-agent-plugin-demo \
status run_... --events
Open the same data directory in Desktop and inspect Lineage. It should show the experiment leading to oscillator-assessment.json. You can also start the Control API and download the JSON from /artifacts.
If contract creation reports acceptance metric has no producer, check metric_outputs in both manifests. If it reports a missing capability, make sure the capability ID is character-for-character identical in all three places.
6. Add fast unit tests
Create test_oscillator_plugin.py next to the plugin directory:
import asyncio
from oscillator_quality.plugin import OscillatorQualityPlugin
from scientific_agent.plugins.sdk import PluginExecutionContext
def test_quality_factor(tmp_path):
plugin = OscillatorQualityPlugin()
context = PluginExecutionContext(
run_id="run_test",
action_id="act_test",
workspace=tmp_path,
)
result = asyncio.run(
plugin.execute(
"tutorial.oscillator.evaluate",
{"angular_frequency": 4.0, "damping_rate": 0.5},
context,
)
)
assert result.success
assert result.metrics == {"quality_factor": 4.0}
assert result.artifacts[0].name == "oscillator-assessment.json"
def test_rejects_nonpositive_damping(tmp_path):
plugin = OscillatorQualityPlugin()
context = PluginExecutionContext("run_test", "act_test", tmp_path)
result = asyncio.run(
plugin.execute(
"tutorial.oscillator.evaluate",
{"angular_frequency": 4.0, "damping_rate": 0.0},
context,
)
)
assert not result.success
assert result.error == "damping_rate must be positive"
Put the plugin package on the import path while testing:
PYTHONPATH=src:examples/plugins/oscillator_quality \
pytest -q test_oscillator_plugin.py
At minimum, test one successful path, one domain-validation failure, and one end-to-end run. If the plugin reads artifacts, also test that wrong types, bad hashes, and cross-run references are rejected.
7. Package and install it in Desktop
From examples/plugins:
cd examples/plugins
zip -r oscillator-quality.zip oscillator_quality \
-x '*__pycache__*' '*.pyc' '.DS_Store'
In Desktop:
- Open Settings → Plugins.
- Choose Install custom plugin.
- Select
oscillator-quality.zip. - Review its manifest and dependencies, then choose Enable.
- Create a run and select
tutorial.oscillator.evaluate.
If installation succeeds but enabling fails, the usual causes are a bad entrypoint or missing dependency. Extract the ZIP and verify that plugin.yaml and oscillator_quality/plugin.py have the relative positions shown here.
8. Turn the teaching plugin into a real one
Extend it one concern at a time:
- Add mass, stiffness, or measurement fields to
input_schema; updaterequired_inputsandexampletogether. - Accept CSV/JSON artifacts through
artifact_inputs, then read them fromcontext.resolved_artifactsorread_artifact(). - Use
parent_namesto link several artifacts produced in one call. - Implement
estimate_cost()for solver, CPU, GPU, or training-sample estimates. - Report actual consumption through
resource_usagefor accurate budget accounting. - Initialize reusable resources in
start()and release them instop(). - Remove the teaching Reasoner and let the general Scientific Reasoner orchestrate the capability.
Never report invented metrics merely to pass a criterion, and do not label an ordinary plugin as a trusted evaluator. Trusted evaluation needs an independent data boundary, explicit authority, and additional tests.
Pre-release checklist
plugin.yamlexplains purpose, inputs, outputs, and dependencies on its own.- Plugin IDs, capability IDs, and artifact kinds are stable and namespaced.
- YAML and Python manifests agree.
- Every input has type and domain validation with actionable errors.
metric_outputsmatches actualPluginResult.metrics.- Artifacts are reviewable, with method and provenance in metadata.
- The plugin neither writes Runtime data directories nor reads other runs or private evaluation data.
- Dependency versions have bounds, and plugin code never installs packages itself.
- Unit tests, ZIP installation, and a real end-to-end run all pass.
See Plugin SDK reference for the full field and lifecycle reference.