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:

  • metrics contains only finite comparable numbers;
  • observations contains a JSON-serializable summary;
  • artifacts contains the durable evidence;
  • context.run_id and action_id may 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:

  1. Open Settings → Plugins.
  2. Choose Install custom plugin.
  3. Select oscillator-quality.zip.
  4. Review its manifest and dependencies, then choose Enable.
  5. 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; update required_inputs and example together.
  • Accept CSV/JSON artifacts through artifact_inputs, then read them from context.resolved_artifacts or read_artifact().
  • Use parent_names to link several artifacts produced in one call.
  • Implement estimate_cost() for solver, CPU, GPU, or training-sample estimates.
  • Report actual consumption through resource_usage for accurate budget accounting.
  • Initialize reusable resources in start() and release them in stop().
  • 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.yaml explains 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_outputs matches actual PluginResult.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.

results matching ""

    No results matching ""