ad-sdl/madsci/madsci-nodes

Claude Code and agent skill by ad-sdl/madsci

Trust tier 2
  • community
LLM review pending
Source check pending

Checking that the SKILL.md file is still in the repository.

Working with MADSci node modules for laboratory instrument integration. Use when creating, modifying, debugging, or testing node modules that interface with scientific instruments.

Install

npx vskill@latest install ad-sdl/madsci/madsci-nodes

SKILL.md

MADSci Node Modules

Node modules are the instrument integration layer of MADSci. Each node wraps a laboratory instrument (robot arm, plate reader, liquid handler, etc.) behind a standard API. The architecture is: AbstractNode (protocol-agnostic base) -> RestNode (FastAPI HTTP server). Both use MadsciClientMixin for lazy access to Event, Resource, and Data clients.

Key Files

FilePurpose
src/madsci_node_module/madsci/node_module/abstract_node_module.pyBase class with lifecycle, action dispatch, type analysis
src/madsci_node_module/madsci/node_module/rest_node_module.pyFastAPI server, file upload/download, rate limiting
src/madsci_node_module/madsci/node_module/helpers.py@action decorator
src/madsci_node_module/madsci/node_module/type_analyzer.pyRecursive type hint analysis (TypeInfo)
src/madsci_common/madsci/common/types/node_types.pyNodeConfig, RestNodeConfig, NodeStatus, NodeInfo, NodeCapabilities, NodeIntrinsicLocationDefinition
src/madsci_client/madsci/client/node/rest_node_client.pyHTTP client for node communication
src/madsci_common/madsci/common/bundled_templates/node/basic/madsci new node template

Creating a Node

1. Scaffold with template
bash
madsci new node
# or: madsci new module (full module with interface, types, tests, Dockerfile)
2. Minimal RestNode
python
from madsci.node_module.rest_node_module import RestNode
from madsci.node_module.helpers import action
class MyInstrumentNode(RestNode):
"""Node for controlling MyInstrument."""
def startup_handler(self):
"""Initialize instrument connections."""
self.instrument = MyInstrumentDriver()
def shutdown_handler(self):
"""Clean up instrument connections."""
self.instrument.disconnect()
@action(name="measure", description="Take a measurement")
def measure(self, sample_id: str, temperature: float = 25.0) -> dict:
result = self.instrument.measure(sample_id, temp=temperature)
return {"sample_id": sample_id, "value": result}
if __name__ == "__main__":
node = MyInstrumentNode()
node.start_node()
3. Configuration

Nodes use NodeConfig (AbstractNode) or RestNodeConfig (RestNode), both inheriting from MadsciBaseSettings:

python
# RestNodeConfig key fields:
node_name: Optional[str] # Human name (defaults to class name)
node_id: Optional[str] # ULID (auto-generated)
node_type: Optional[NodeType] # DEVICE, COMPUTE, etc.
node_url: AnyUrl # Default: http://127.0.0.1:2000
status_update_interval: float # Default: 2.0s
state_update_interval: float # Default: 2.0s
enable_registry_resolution: bool # Default: True
enable_rate_limiting: bool # Default: True

Config is discovered via walk-up file search (see CLAUDE.md). Override with environment variables using NODE_ prefix or pass directly:

python
node = MyNode(node_config=RestNodeConfig(node_url="http://0.0.0.0:3000"))
Deprecated: NodeDefinition

NodeDefinition is deprecated as of v0.7.0. Node identity now comes from NodeConfig/RestNodeConfig plus the ID Registry.

  • Do not use NodeDefinition or *.node.yaml definition files in new code
  • Use NodeConfig or RestNodeConfig (both are MadsciBaseSettings subclasses)
  • Run madsci migrate to convert legacy *.node.yaml files

The @action Decorator

Register methods as node actions. Discovered automatically at init.

python
from madsci.node_module.helpers import action
# With explicit parameters
@action(name="dispense", description="Dispense liquid", blocking=True)
def dispense(self, volume_ul: float, well: str) -> dict:
...
# Minimal (name from function, description from docstring, blocking=True)
@action
def home(self):
"""Home all axes to reference position."""
self.instrument.home()

Parameters:

  • name (str, optional): Action name (defaults to function name)
  • description (str, optional): Description (defaults to docstring)
  • blocking (bool, default True): Whether the action blocks concurrent actions via _action_lock

Behind the scenes: Sets __is_madsci_action__ = True on the function. AbstractNode.__init__() scans __class__.__dict__ for this flag and auto-registers via _add_action().

Action Parameters and Return Types

Regular parameters

Standard Python types become JSON-serializable action arguments:

python
@action
def process(self, sample_id: str, count: int = 1, options: Optional[dict] = None) -> dict:
...
File parameters (use Path, not UploadFile)
python
from pathlib import Path
@action
def analyze_image(self, image: Path) -> dict:
"""Single file upload."""
data = image.read_bytes()
return {"size": len(data)}
@action
def batch_process(self, files: list[Path]) -> dict:
"""Multiple file upload."""
return {"count": len(files)}
Location parameters
python
from madsci.common.types.location_types import LocationArgument
@action
def transfer(self, source: LocationArgument, destination: LocationArgument) -> dict:
...
Annotated parameters (extra metadata)
python
from typing import Annotated
@action
def calibrate(self, offset: Annotated[float, "Offset in mm, range -10 to 10"]) -> dict:
...
Return types
python
from madsci.common.types.action_types import ActionFiles, ActionDatapoints
from madsci.common.types.data_types import DataPoint
# Plain dict/Pydantic model -> json_result
@action
def measure(self) -> dict:
return {"value": 42}
# Path -> single file result
@action
def capture(self) -> Path:
return Path("/tmp/image.png")
# ActionFiles -> multiple named files
@action
def generate_report(self) -> ActionFiles:
return ActionFiles(report=Path("report.pdf"), data=Path("data.csv"))
# Tuple -> (json_result, files, datapoints)
@action
def full_result(self) -> tuple[dict, ActionFiles, ActionDatapoints]:
return (
{"status": "ok"},
ActionFiles(output=Path("out.csv")),
ActionDatapoints(dp=DataPoint(label="measurement", value=42)),
)

Node Lifecycle

__init__()
├── Load NodeConfig (walk-up discovery)
├── Synthesize NodeInfo
├── Resolve registry identity (if enabled)
├── Configure clients (EventClient, ResourceClient, DataClient)
└── Discover @action methods
start_node()
├── Establish event_client_context
├── Log startup
└── _startup() thread:
├── startup_handler() ← Override this
├── template_handler() ← Override for resource/location templates
└── Status/state update loops (every N seconds)
├── status_handler() ← Override to populate node_status
└── state_handler() ← Override to populate node_state
Per-action execution:
├── Parse args (TypeAnalyzer)
├── Validate required args
├── Check node ready (not busy/locked/errored)
├── Execute in daemon thread (with _action_lock if blocking)
├── Process result -> ActionResult
└── Update action_history & log status
shutdown:
├── shutdown_handler() ← Override for cleanup
├── Release registry identity
└── Teardown clients

REST API Endpoints (RestNode)

Standard endpoints
MethodPathDescription
GET/statusNode operational status
GET/infoNode metadata, actions, capabilities
GET/stateCustom state dict
POST/configUpdate config fields
GET/logEventClient logs
POST/admin/{command}Admin commands (lock, unlock, shutdown)
GET/actionAction history
Per-action endpoints (3-phase file upload)
MethodPathDescription
POST/action/{name}Create action, get action_id
POST/action/{name}/{id}/upload/{param}Upload file(s) for a parameter
POST/action/{name}/{id}/startStart execution
GET/action/{name}/{id}/statusPoll action status
GET/action/{name}/{id}/resultGet result (files as key list)
GET/action/{id}/downloadDownload all files as ZIP

NodeStatus

python
# Operational flags:
busy: bool # Currently executing an action
paused: bool # Manually paused
locked: bool # Locked via admin command
stopped: bool # Node stopped
errored: bool # Node in error state
disconnected: bool # Lost connection
initializing: bool # Still starting up
# Computed:
ready: bool # True only if no flags set and no waiting_for_config
description: str # Human-readable reason if not ready

Resource/Location Template Registration

Declare templates as ClassVar lists for automatic registration at startup:

python
class MyNode(RestNode):
resource_templates: ClassVar[list[NodeResourceTemplateDefinition]] = [
NodeResourceTemplateDefinition(
template_name="sample_plate",
description="96-well sample plate",
version="1.0",
),
]
# Representation templates define how this node "sees" a location
location_representation_templates: ClassVar[
list[NodeRepresentationTemplateDefinition]
] = [
NodeRepresentationTemplateDefinition(
template_name="mynode_slot_repr",
default_values={"slot_index": 0},
required_overrides=["slot_index"],
version="1.0.0",
),
]
# Intrinsic locations are auto-created on startup with '{node_name}.' prefix
intrinsic_locations: ClassVar[list[NodeIntrinsicLocationDefinition]] = [
NodeIntrinsicLocationDefinition(
location_name="slot_1", # becomes "{node_name}.slot_1"
representation_template_name="mynode_slot_repr",
representation_overrides={"slot_index": 1},
resource_template_name="sample_plate",
),
]
def template_handler(self):
"""Called at startup to register templates. Override for custom logic."""
super().template_handler()

Datapoint Helpers

Upload data to the Data Manager from within actions:

python
@action
def measure(self) -> dict:
value = self.instrument.read()
# Single value datapoint
dp_id = self.create_and_upload_value_datapoint(value=value, label="measurement")
# File datapoint
file_id = self.create_and_upload_file_datapoint(
file_path=Path("output.csv"), label="raw_data"
)
# Custom datapoint
from madsci.common.types.data_types import DataPoint
dp = DataPoint(label="custom", value={"nested": "data"})
self.upload_datapoint(dp)
return {"measurement": value, "datapoint_id": dp_id}

Testing Nodes

python
from fastapi.testclient import TestClient
def test_node_action():
node = MyInstrumentNode(
node_config=RestNodeConfig(
enable_registry_resolution=False, # Critical for tests
node_url="http://127.0.0.1:2000",
)
)
node.start_node(testing=True) # Configures routes without starting uvicorn
client = TestClient(node.rest_api)
# Check info
response = client.get("/info")
assert response.status_code == 200
assert "measure" in response.json()["actions"]
# Run action (no file params)
response = client.post("/action/measure", json={"sample_id": "ABC123"})
assert response.status_code == 200
action_id = response.json()["action_id"]
# Start and get result
client.post(f"/action/measure/{action_id}/start")
import time; time.sleep(0.5)
result = client.get(f"/action/measure/{action_id}/result")
assert result.json()["json_result"]["sample_id"] == "ABC123"

Common Pitfalls

This is the start of the file. Read the full SKILL.md on GitHub.

Security

  • Security scanPassed, 100 of 100Pattern checks on the skill's files.
  • Model reviewNot runA model review is scheduled for the next rescan.

Quality evaluation

  • Task evaluationNot runNo evaluation yet. Run one in Skill Studio.

Source

Popularity

Repository stars
82
Repository forks
11
7-day trend score
22

Stars and forks are for the whole GitHub repository, not this skill alone. Not refreshed yet.

Works with

Any agent vskill installs to: 56 targets in all. These 39 have a page on this site.

  • Claude Code
  • Cursor
  • GitHub Copilot
  • Windsurf
  • Codex
  • Gemini CLI
  • Cline
  • Amp
  • Roo Code
  • Goose
  • Continue
  • Kilo Code
  • Junie
  • Trae
  • OpenHands
  • Qwen Code
  • AI Agent
  • Tabnine Chat
  • Sourcegraph Cody
  • Amazon Q
  • Firebase Genkit
  • Sweep
  • Devin
  • Phind
  • Mentat
  • Claude Desktop
  • Aider
  • GPT Pilot
  • Plandex
  • bolt.new
  • v0
  • Replit Agent
  • Anse
  • TypingMind
  • BetterChatGPT
  • LibreChat
  • Warp AI
  • Pieces
  • Fig AI
Embed badge
vskill VERIFIED badge
[![vskill VERIFIED](https://verified-skill.com/api/v1/skills/ad-sdl/madsci/madsci-nodes/badge)](https://verified-skill.com/skills/ad-sdl/madsci/madsci-nodes)

Something wrong with this skill or its page? Tell us.