Write a driver module

You want to use hardware that Viam doesn’t support out of the box, whether it’s a sensor, camera, motor, or any other component. A driver module bridges that gap: it implements a standard Viam resource API so that data capture, the Test section, the SDKs, and other platform features work with your hardware automatically.

Driver modules run as separate processes alongside viam-server, so they carry their own dependencies and can crash without bringing viam-server down. You package and distribute them through the Viam registry.

This page walks through seven steps for writing a driver module, using a temperature-and-humidity sensor as the worked example. For background on choosing a resource API, module lifecycle, and dependencies, see the overview.

Steps

When writing a module, follow the steps outlined below. To illustrate each step we’ll use a sensor module as a worked example. The same patterns apply to any resource type – substitute the appropriate API and methods for your use case.

1. Generate the module

Before you run the generator, install the Viam CLI and log in with viam login.

The generator prompts for your organization’s public namespace. If you have not set one yet, click the organization dropdown at the upper right of the Viam app, select Settings, then Set a public namespace. You can also enter your Org ID at the prompt instead.

Run the Viam CLI generator:

viam module generate

The generator creates a new directory named after your module (for example, my-sensor-module) in your current working directory. cd into that directory for the rest of the steps.

When run without flags, the generator prompts for each value below. If you pass these as --name, --language, --visibility, --public-namespace, --resource-subtype, --model-name, and --register flags instead, use the flag forms noted in the table (where different from the interactive labels).

PromptWhat to enterWhy
Set a module name:my-sensor-moduleA short, descriptive name
Specify the language for the module:python or goYour implementation language
Visibility:privateprivate: visible only within your org. public: visible to everyone. public_unlisted: usable by anyone who knows the module ID, but hidden from the registry page. You can change visibility later.
Namespace/Organization IDYour organization namespaceScopes the module to your org
Select a resource to be added to the module:Sensor ComponentThe resource API to implement
Set a model name of the resource:my-sensorThe model name for your sensor
Register moduleyesRegisters the module with Viam

The generator creates a complete project with the following files:

FilePurpose
src/main.pyEntry point – starts the module server
src/models/my_sensor.pyResource class skeleton – you will edit this
requirements.txtPython dependencies
meta.jsonModule metadata for the registry
setup.shInstalls dependencies into a virtualenv
build.shPackages the module for upload
.github/workflows/deploy.ymlCI workflow for cloud builds
FilePurpose
cmd/module/main.goEntry point – starts the module server
module.goResource implementation skeleton – you will edit this
go.modGo module definition
MakefileBuild targets
meta.jsonModule metadata for the registry
.github/workflows/deploy.ymlCI workflow for cloud builds

2. Implement the resource API

Open the generated resource file: src/models/my_sensor.py (Python) or module.go (Go). The generator creates a class (Python) or struct (Go) with stub methods. You need to make four changes to the resource file, then review the entry point the generator created:

  1. Define your config attributes.
  2. Add validation logic.
  3. Populate your resource from config in the constructor.
  4. Implement the API methods for your resource type.

The following example builds a sensor that reads temperature and humidity from a custom HTTP API endpoint. Replace the HTTP call with whatever data source your sensor uses.

Define your config attributes

Config attributes are the fields a user sets when they configure your component in the Viam app. The generator creates an empty config; add a field for each attribute your module needs.

In src/models/my_sensor.py, declare your config attributes as type-annotated class variables. They will be populated from the config in new:

class MySensor(Sensor, EasyResource):
    # To enable debug-level logging, either run viam-server with the --debug option,
    # or configure your resource/machine to display debug logs.
    MODEL: ClassVar[Model] = Model(
        ModelFamily("my-org", "my-sensor-module"), "my-sensor"
    )

    # Add your config attributes as instance variables
    source_url: str
    poll_interval: float

In module.go, find the empty Config struct and add fields. Each field needs a json tag that matches the attribute name users will set in their config JSON:

type Config struct {
    SourceURL    string  `json:"source_url"`
    PollInterval float64 `json:"poll_interval"`
}

Add validation logic

The generator creates an empty validation method. Add checks for required fields and return any dependencies your module needs.

Find validate_config in your class and add validation:

    @classmethod
    def validate_config(
        cls, config: ComponentConfig
    ) -> Tuple[Sequence[str], Sequence[str]]:
        fields = config.attributes.fields
        if "source_url" not in fields:
            raise Exception("source_url is required")
        if not fields["source_url"].string_value.startswith("http"):
            raise Exception("source_url must be an HTTP or HTTPS URL")
        return [], []  # No required or optional dependencies

Find the Validate method on your Config struct and add validation:

func (cfg *Config) Validate(path string) ([]string, []string, error) {
    if cfg.SourceURL == "" {
        return nil, nil, fmt.Errorf("source_url is required")
    }
    return nil, nil, nil // No required or optional dependencies
}

The validation method returns two lists: required dependencies and optional dependencies. In Go, the method also returns an error as a third value; in Python, raise an exception instead. For a standalone sensor with no dependencies, return empty lists and no error. See Step 5 for modules that depend on other components.

Populate your resource from config

Your constructor runs on both initial creation and every config change, so read config fields and initialize state there. When a user changes the configuration, viam-server stops the existing resource instance and creates a fresh one with the new config.

Override new to read your config and set the fields on the instance before returning it:

    @classmethod
    def new(cls, config: ComponentConfig,
            dependencies: Mapping[ResourceName, ResourceBase]) -> Self:
        sensor = super().new(config, dependencies)
        fields = config.attributes.fields
        sensor.source_url = fields["source_url"].string_value
        sensor.poll_interval = (
            fields["poll_interval"].number_value
            if "poll_interval" in fields
            else 10.0
        )
        return sensor

The generator also emits do_command, get_status, and get_geometries stubs that raise NotImplementedError. Leave them as-is unless your module needs custom handling.

The generator produces two constructor functions: a private newMySensorModuleMySensor that unpacks the raw config and delegates to a public NewMySensor that takes a typed *Config. The split lets tests call NewMySensor directly with a typed config. Leave newMySensorModuleMySensor alone and update NewMySensor to initialize any state your module needs:

func NewMySensor(ctx context.Context, deps resource.Dependencies, name resource.Name, conf *Config, logger logging.Logger) (sensor.Sensor, error) {
    cancelCtx, cancelFunc := context.WithCancel(context.Background())

    timeout := time.Duration(conf.PollInterval) * time.Second
    if timeout == 0 {
        timeout = 10 * time.Second
    }

    s := &mySensorModuleMySensor{
        name:       name,
        logger:     logger,
        cfg:        conf,
        cancelCtx:  cancelCtx,
        cancelFunc: cancelFunc,
        client:     &http.Client{Timeout: timeout},
    }
    return s, nil
}

Add fields to the generated struct for any state your module needs at runtime:

type mySensorModuleMySensor struct {
    resource.AlwaysRebuild

    name resource.Name

    logger logging.Logger
    cfg    *Config

    cancelCtx  context.Context
    cancelFunc func()
    client     *http.Client
}

Leave the generated resource.AlwaysRebuild embed in place. The generated Name(), Close(), DoCommand(), and Status() methods also stay. Close calls cancelFunc() to stop any background work. DoCommand and Status are stubs returning fmt.Errorf("not implemented"). Leave them as-is unless your module needs custom handling.

Implement the API method

For a sensor, the key method is GetReadings, which returns a map of reading names to values. This is the method that data capture calls and your application code queries.

The generator creates a stub that returns an error. Replace it with your implementation:

Add a get_readings method to your class. The return type is Mapping[str, SensorReading] (import SensorReading from viam.utils):

    async def get_readings(
        self,
        *,
        extra: Optional[Mapping[str, Any]] = None,
        timeout: Optional[float] = None,
        **kwargs,
    ) -> Mapping[str, SensorReading]:
        try:
            response = requests.get(self.source_url, timeout=5)
            response.raise_for_status()
            data = response.json()
            return {
                "temperature": data["temp"],
                "humidity": data["humidity"],
            }
        except requests.RequestException as e:
            self.logger.error(f"Failed to read from {self.source_url}: {e}")
            raise

Find the Readings method stub and replace it:

type sensorResponse struct {
    Temp     float64 `json:"temp"`
    Humidity float64 `json:"humidity"`
}

func (s *mySensorModuleMySensor) Readings(ctx context.Context, extra map[string]interface{}) (map[string]interface{}, error) {
    req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.cfg.SourceURL, nil)
    if err != nil {
        return nil, fmt.Errorf("building request: %w", err)
    }
    resp, err := s.client.Do(req)
    if err != nil {
        s.logger.CErrorw(ctx, "failed to read from source",
            "url", s.cfg.SourceURL, "error", err)
        return nil, fmt.Errorf("failed to read from %s: %w", s.cfg.SourceURL, err)
    }
    defer resp.Body.Close()

    var data sensorResponse
    if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
        return nil, fmt.Errorf("failed to decode response: %w", err)
    }

    return map[string]interface{}{
        "temperature": data.Temp,
        "humidity":    data.Humidity,
    }, nil
}
View the complete resource file

For reference, here is the complete resource file after all the changes above. my-org in these samples stands in for the namespace you entered when running the generator.

src/models/my_sensor.py:

from typing import (Any, ClassVar, Dict, Final, List, Mapping, Optional,
                    Sequence, Tuple)

import requests
from typing_extensions import Self
from viam.components.sensor import *
from viam.proto.app.robot import ComponentConfig
from viam.proto.common import Geometry, ResourceName
from viam.resource.base import ResourceBase
from viam.resource.easy_resource import EasyResource
from viam.resource.types import Model, ModelFamily
from viam.utils import SensorReading, ValueTypes


class MySensor(Sensor, EasyResource):
    # To enable debug-level logging, either run viam-server with the --debug option,
    # or configure your resource/machine to display debug logs.
    MODEL: ClassVar[Model] = Model(
        ModelFamily("my-org", "my-sensor-module"), "my-sensor"
    )

    source_url: str
    poll_interval: float

    @classmethod
    def new(
        cls, config: ComponentConfig, dependencies: Mapping[ResourceName, ResourceBase]
    ) -> Self:
        """This method creates a new instance of this Sensor component.
        The default implementation sets the name from the `config` parameter.

        Args:
            config (ComponentConfig): The configuration for this resource
            dependencies (Mapping[ResourceName, ResourceBase]): The dependencies (both required and optional)

        Returns:
            Self: The resource
        """
        sensor = super().new(config, dependencies)
        fields = config.attributes.fields
        sensor.source_url = fields["source_url"].string_value
        sensor.poll_interval = (
            fields["poll_interval"].number_value
            if "poll_interval" in fields
            else 10.0
        )
        return sensor

    @classmethod
    def validate_config(
        cls, config: ComponentConfig
    ) -> Tuple[Sequence[str], Sequence[str]]:
        """This method allows you to validate the configuration object received from the machine,
        as well as to return any required dependencies or optional dependencies based on that `config`.

        Args:
            config (ComponentConfig): The configuration for this resource

        Returns:
            Tuple[Sequence[str], Sequence[str]]: A tuple where the
                first element is a list of required dependencies and the
                second element is a list of optional dependencies
        """
        fields = config.attributes.fields
        if "source_url" not in fields:
            raise Exception("source_url is required")
        if not fields["source_url"].string_value.startswith("http"):
            raise Exception("source_url must be an HTTP or HTTPS URL")
        return [], []

    async def get_readings(
        self,
        *,
        extra: Optional[Mapping[str, Any]] = None,
        timeout: Optional[float] = None,
        **kwargs
    ) -> Mapping[str, SensorReading]:
        try:
            response = requests.get(self.source_url, timeout=5)
            response.raise_for_status()
            data = response.json()
            return {
                "temperature": data["temp"],
                "humidity": data["humidity"],
            }
        except requests.RequestException as e:
            self.logger.error(f"Failed to read from {self.source_url}: {e}")
            raise

    async def do_command(
        self,
        command: Mapping[str, ValueTypes],
        *,
        timeout: Optional[float] = None,
        **kwargs
    ) -> Mapping[str, ValueTypes]:
        self.logger.error("`do_command` is not implemented")
        raise NotImplementedError()

    async def get_status(
        self, *, timeout: Optional[float] = None, **kwargs
    ) -> Mapping[str, ValueTypes]:
        self.logger.error("`get_status` is not implemented")
        raise NotImplementedError()

    async def get_geometries(
        self, *, extra: Optional[Dict[str, Any]] = None, timeout: Optional[float] = None
    ) -> Sequence[Geometry]:
        self.logger.error("`get_geometries` is not implemented")
        raise NotImplementedError()

module.go:

package mysensormodule

import (
    "context"
    "encoding/json"
    "errors"
    "fmt"
    "net/http"
    "time"

    sensor "go.viam.com/rdk/components/sensor"
    "go.viam.com/rdk/logging"
    "go.viam.com/rdk/resource"
)

var (
    MySensor         = resource.NewModel("my-org", "my-sensor-module", "my-sensor")
    errUnimplemented = errors.New("unimplemented")
)

type Config struct {
    SourceURL    string  `json:"source_url"`
    PollInterval float64 `json:"poll_interval"`
}

func (cfg *Config) Validate(path string) ([]string, []string, error) {
    if cfg.SourceURL == "" {
        return nil, nil, fmt.Errorf("source_url is required")
    }
    return nil, nil, nil
}

func init() {
    resource.RegisterComponent(sensor.API, MySensor,
        resource.Registration[sensor.Sensor, *Config]{
            Constructor: newMySensorModuleMySensor,
        },
    )
}

type mySensorModuleMySensor struct {
    resource.AlwaysRebuild

    name resource.Name

    logger logging.Logger
    cfg    *Config

    cancelCtx  context.Context
    cancelFunc func()
    client     *http.Client
}

func newMySensorModuleMySensor(ctx context.Context, deps resource.Dependencies, rawConf resource.Config, logger logging.Logger) (sensor.Sensor, error) {
    conf, err := resource.NativeConfig[*Config](rawConf)
    if err != nil {
        return nil, err
    }
    return NewMySensor(ctx, deps, rawConf.ResourceName(), conf, logger)
}

func NewMySensor(ctx context.Context, deps resource.Dependencies, name resource.Name, conf *Config, logger logging.Logger) (sensor.Sensor, error) {
    cancelCtx, cancelFunc := context.WithCancel(context.Background())

    timeout := time.Duration(conf.PollInterval) * time.Second
    if timeout == 0 {
        timeout = 10 * time.Second
    }

    s := &mySensorModuleMySensor{
        name:       name,
        logger:     logger,
        cfg:        conf,
        cancelCtx:  cancelCtx,
        cancelFunc: cancelFunc,
        client:     &http.Client{Timeout: timeout},
    }
    return s, nil
}

func (s *mySensorModuleMySensor) Name() resource.Name {
    return s.name
}

type sensorResponse struct {
    Temp     float64 `json:"temp"`
    Humidity float64 `json:"humidity"`
}

func (s *mySensorModuleMySensor) Readings(ctx context.Context, extra map[string]interface{}) (map[string]interface{}, error) {
    req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.cfg.SourceURL, nil)
    if err != nil {
        return nil, fmt.Errorf("building request: %w", err)
    }
    resp, err := s.client.Do(req)
    if err != nil {
        s.logger.CErrorw(ctx, "failed to read from source",
            "url", s.cfg.SourceURL, "error", err)
        return nil, fmt.Errorf("failed to read from %s: %w", s.cfg.SourceURL, err)
    }
    defer resp.Body.Close()

    var data sensorResponse
    if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
        return nil, fmt.Errorf("failed to decode response: %w", err)
    }

    return map[string]interface{}{
        "temperature": data.Temp,
        "humidity":    data.Humidity,
    }, nil
}

func (s *mySensorModuleMySensor) DoCommand(ctx context.Context, cmd map[string]interface{}) (map[string]interface{}, error) {
    return nil, fmt.Errorf("not implemented")
}

func (s *mySensorModuleMySensor) Status(ctx context.Context) (map[string]interface{}, error) {
    return nil, fmt.Errorf("not implemented")
}

func (s *mySensorModuleMySensor) Close(context.Context) error {
    // Put close code here
    s.cancelFunc()
    return nil
}

Understanding the entry point

The generator also creates the entry point file that viam-server launches. You typically do not need to modify it.

src/main.py:

import asyncio
from viam.module.module import Module
from models.my_sensor import MySensor as MySensorModel


if __name__ == '__main__':
    asyncio.run(Module.run_from_registry())

run_from_registry() automatically discovers all imported resource classes and registers them with viam-server. If you add more models to your module, import them here.

cmd/module/main.go:

package main

import (
    "mysensormodule"

    "go.viam.com/rdk/module"
    "go.viam.com/rdk/resource"
    sensor "go.viam.com/rdk/components/sensor"
)

func main() {
    // ModularMain can take multiple APIModel arguments, if your module implements multiple models.
    module.ModularMain(resource.APIModel{sensor.API, mysensormodule.MySensor})
}

ModularMain handles socket parsing, signal handling, and graceful shutdown. The import of the resource package triggers its init() function, which calls resource.RegisterComponent to register the model. If you add more models, add more resource.APIModel entries to the ModularMain call.

3. Test locally

Use the CLI to build and deploy your module to a machine, then verify it works. Two commands cover the common development loop: viam module reload (cloud build) for cross-architecture work, and viam module reload-local (local build) for same-architecture iteration. Use reload when developing on a different architecture than your target, for example on macOS deploying to a Raspberry Pi. Use reload-local when architectures match for faster iteration.

Deploy with hot reloading:

First, find your machine’s part ID. At the top of the machine’s page, click the Live / Offline status dropdown, then click Part ID to copy it. In the commands below, --model-name adds an instance of your model to the machine config so you don’t have to create it by hand, and --name names that instance. Run these commands from your module’s root directory (where meta.json lives). If you need to invoke them from elsewhere, pass --module <path/to/meta.json>.

# Build in the cloud, deploy, and add a component named `my-sensor-1`
viam module reload --part-id <machine-part-id> \
  --model-name my-org:my-sensor-module:my-sensor --name my-sensor-1
# Build locally (same-architecture only), transfer, and add the component
viam module reload-local --part-id <machine-part-id> \
  --model-name my-org:my-sensor-module:my-sensor --name my-sensor-1

After the first reload succeeds, open the machine’s CONFIGURE tab and set your new sensor’s attributes. Replace source_url with a real endpoint that returns JSON matching the shape your Readings implementation expects (the example reads temp and humidity keys from the response body):

{
  "source_url": "https://your-endpoint.example.com/readings"
}

Click Save.

Test using the Test section:

  1. Find your sensor component and expand the Test section.
  2. Your temperature and humidity values appear automatically under GetReadings.

Get a ready-to-run code sample:

The CONNECT tab on your machine’s page in the Viam app provides generated code samples in Python and Go that connect to your machine and access all configured components. Use this as a starting point for application code that interacts with your module.

Rebuild and redeploy during development:

Each time you make changes, run viam module reload (or reload-local) again. Use --no-build to skip the build step if you already built manually. Use viam module restart to restart without rebuilding (for example, after editing Python source).

4. Add logging

Both the Python and Go SDKs provide a logger that writes to viam-server’s log stream, visible in the LOGS tab.

self.logger.info("Sensor initialized with source URL: %s", self.source_url)
self.logger.debug("Raw response from source: %s", data)
self.logger.warning("Source returned unexpected field: %s", field_name)
self.logger.error("Failed to connect to source: %s", error)
s.logger.CInfof(ctx, "Sensor initialized with source URL: %s", s.cfg.SourceURL)
s.logger.CDebugf(ctx, "Raw response from source: %v", data)
s.logger.CWarnw(ctx, "Source returned unexpected field", "field", fieldName)
s.logger.CErrorw(ctx, "Failed to connect to source", "error", err)

Use info for significant events, debug for detailed data, warning for recoverable problems, and error for failures.

5. Handle dependencies

Many modules need access to other resources on the same machine. To use another resource, you need to do three things:

  1. Declare the dependency in your config validation method by returning the resource name in the required (or optional) dependencies list.
  2. Resolve the dependency in your constructor by looking it up from the dependencies map that viam-server passes in.
  3. Call methods on it in your API implementation, just like any other typed resource.

The following example shows all three. It implements a sensor that depends on another sensor – it reads Celsius temperature readings from the source sensor and converts them to Fahrenheit. Watch for the numbered comments in the code.

In Go, the example uses sensor.FromProvider(deps, name) to resolve the dependency. It returns a typed sensor.Sensor handle, so your code does not need a type assertion. Every component and service package in the SDK exposes its own FromProvider helper. For a motor dependency, use motor.FromProvider(deps, name). For a camera, use camera.FromProvider(deps, name). And so on.

The Python SDK has no equivalent helper. Iterate the dependencies map and match by ResourceName.name, as the example below shows.

Place TempConverter alongside MySensor. In Python, add it as a new file under src/models/ (for example, src/models/temp_converter.py). In Go, put it in the same package as MySensor, either by extending module.go or adding another .go file in the same directory. For registration, see Step 7.

class TempConverter(Sensor, EasyResource):
    MODEL: ClassVar[Model] = Model(
        ModelFamily("my-org", "my-sensor-module"), "temp-converter"
    )

    source_sensor: Sensor

    @classmethod
    def validate_config(
        cls, config: ComponentConfig
    ) -> Tuple[Sequence[str], Sequence[str]]:
        fields = config.attributes.fields
        if "source_sensor" not in fields:
            raise Exception("source_sensor is required")
        source = fields["source_sensor"].string_value
        # 1. Declare: return the source sensor name as a required dependency
        return [source], []

    @classmethod
    def new(cls, config: ComponentConfig,
            dependencies: Mapping[ResourceName, ResourceBase]) -> Self:
        instance = super().new(config, dependencies)
        source_name = config.attributes.fields["source_sensor"].string_value
        # 2. Resolve: find the dependency in the map viam-server passes in
        for name, dep in dependencies.items():
            if name.name == source_name:
                instance.source_sensor = dep
                break
        return instance

    async def get_readings(self, *, extra=None, timeout=None,
                           **kwargs) -> Mapping[str, SensorReading]:
        # 3. Use: call methods on the dependency like any typed resource
        readings = await self.source_sensor.get_readings()
        celsius = readings["temperature"]
        return {"temperature_f": celsius * 9.0 / 5.0 + 32.0}
var TempConverter = resource.NewModel("my-org", "my-sensor-module", "temp-converter")

func init() {
    resource.RegisterComponent(sensor.API, TempConverter,
        resource.Registration[sensor.Sensor, *ConverterConfig]{
            Constructor: newMySensorModuleTempConverter,
        },
    )
}

type ConverterConfig struct {
    SourceSensor string `json:"source_sensor"`
}

func (cfg *ConverterConfig) Validate(path string) ([]string, []string, error) {
    if cfg.SourceSensor == "" {
        return nil, nil, fmt.Errorf("source_sensor is required")
    }
    // 1. Declare: return the source sensor name as a required dependency
    return []string{cfg.SourceSensor}, nil, nil
}

type mySensorModuleTempConverter struct {
    resource.AlwaysRebuild

    name resource.Name

    logger logging.Logger
    cfg    *ConverterConfig

    cancelCtx  context.Context
    cancelFunc func()
    source     sensor.Sensor
}

func newMySensorModuleTempConverter(
    ctx context.Context,
    deps resource.Dependencies,
    rawConf resource.Config,
    logger logging.Logger,
) (sensor.Sensor, error) {
    conf, err := resource.NativeConfig[*ConverterConfig](rawConf)
    if err != nil {
        return nil, err
    }
    return NewTempConverter(ctx, deps, rawConf.ResourceName(), conf, logger)
}

func NewTempConverter(
    ctx context.Context,
    deps resource.Dependencies,
    name resource.Name,
    conf *ConverterConfig,
    logger logging.Logger,
) (sensor.Sensor, error) {
    cancelCtx, cancelFunc := context.WithCancel(context.Background())

    // 2. Resolve: look up the dependency by name from the map viam-server passes in
    src, err := sensor.FromProvider(deps, conf.SourceSensor)
    if err != nil {
        cancelFunc()
        return nil, fmt.Errorf("source sensor %q not found: %w",
            conf.SourceSensor, err)
    }

    return &mySensorModuleTempConverter{
        name:       name,
        logger:     logger,
        cfg:        conf,
        cancelCtx:  cancelCtx,
        cancelFunc: cancelFunc,
        source:     src,
    }, nil
}

func (s *mySensorModuleTempConverter) Name() resource.Name {
    return s.name
}

func (s *mySensorModuleTempConverter) Readings(
    ctx context.Context,
    extra map[string]interface{},
) (map[string]interface{}, error) {
    // 3. Use: call methods on the dependency like any typed resource
    readings, err := s.source.Readings(ctx, extra)
    if err != nil {
        return nil, fmt.Errorf("failed to read source sensor: %w", err)
    }
    celsius, ok := readings["temperature"].(float64)
    if !ok {
        return nil, fmt.Errorf("source sensor did not return a temperature reading")
    }
    return map[string]interface{}{
        "temperature_f": celsius*9.0/5.0 + 32.0,
    }, nil
}

func (s *mySensorModuleTempConverter) DoCommand(ctx context.Context, cmd map[string]interface{}) (map[string]interface{}, error) {
    return nil, fmt.Errorf("not implemented")
}

func (s *mySensorModuleTempConverter) Status(ctx context.Context) (map[string]interface{}, error) {
    return nil, fmt.Errorf("not implemented")
}

func (s *mySensorModuleTempConverter) Close(ctx context.Context) error {
    s.cancelFunc()
    return nil
}

TempConverter is a second model in the same module. To register it alongside your first model, see Step 7.

6. Use the module data directory

Every module gets a persistent data directory at the path specified by the VIAM_MODULE_DATA environment variable. Use this for caches, databases, or any state that should survive module restarts.

import os

data_dir = os.environ.get("VIAM_MODULE_DATA", "/tmp")
cache_path = os.path.join(data_dir, "readings_cache.json")
dataDir := os.Getenv("VIAM_MODULE_DATA")
cachePath := filepath.Join(dataDir, "readings_cache.json")

The directory is created automatically by viam-server at $VIAM_HOME/module-data/<machine-id>/<module-name>/ (where VIAM_HOME defaults to ~/.viam) and persists across module restarts and reconfigurations.

7. Add multiple models to one module (optional)

A single module can provide multiple models, even across different APIs (for example, a sensor and a camera). There is no limit on the number of models per module.

To add a second model:

  1. Run viam module generate again in a separate directory. You only need the generated resource file, so skip the registration prompt.
  2. Copy that file into your existing module’s source directory. In Go, rename three things before the file compiles: the compound struct to <yourModuleName><Model>, the config struct to something unique like ConverterConfig, and resource.NewModel’s middle argument to your existing module name. The Step 5 TempConverter example shows the result.
  3. Update the entry point to register both models.

Import the new model in src/main.py. run_from_registry() automatically discovers all imported resource classes:

import asyncio
from viam.module.module import Module
from models.my_sensor import MySensor as MySensorModel
from models.my_camera import MyCamera as MyCameraModel

if __name__ == '__main__':
    asyncio.run(Module.run_from_registry())

Add more resource.APIModel entries to ModularMain:

package main

import (
    "mysensormodule"

    "go.viam.com/rdk/module"
    "go.viam.com/rdk/resource"
    camera "go.viam.com/rdk/components/camera"
    sensor "go.viam.com/rdk/components/sensor"
)

func main() {
    // ModularMain can take multiple APIModel arguments, if your module implements multiple models.
    module.ModularMain(
        resource.APIModel{sensor.API, mysensormodule.MySensor},
        resource.APIModel{camera.API, mysensormodule.MyCamera},
    )
}

Each model needs its own init() function calling resource.RegisterComponent (or resource.RegisterService) with its API, model, and constructor.

  1. Delete the temporary generated directory.
  2. Update meta.json to list all models (or use viam module update-models --binary ./bin/module to detect them automatically from a Go binary).

Try It

  1. Generate a sensor module using viam module generate.
  2. Open the generated resource file and implement your config validation and GetReadings method.
  3. Configure the module on your machine as a local module.
  4. Expand the Test section on your sensor. Verify readings appear automatically under GetReadings.
  5. Enable data capture on the sensor. Wait one minute, then check the DATA tab to confirm readings are flowing to the cloud.
  6. Add a new key to the readings map (for example, "pressure": 1013.25). Rebuild and redeploy with viam module reload-local. Verify the new reading appears on the Test section.

Troubleshooting

Module crashes on startup
  • Check the LOGS tab for the crash traceback. The most common cause is a missing dependency – a Python import not in requirements.txt or a Go package not in go.mod.
  • For Python, verify the module runs outside of viam-server: python3 -m src.main (from your module directory, with the virtualenv activated).
  • For Go, verify the binary runs: ./bin/<your-module-name> (the output path is set in your Makefile).
Module times out on startup

viam-server expects the module to complete startup within 5 minutes (the default VIAM_MODULE_STARTUP_TIMEOUT). If your module does heavy initialization (loading large files, connecting to slow services), it may time out.

  • Move slow initialization out of init() or model registration and into the constructor instead, where it runs per-resource rather than blocking module startup.
  • Check the LOGS tab for timeout errors.
Dependency not found
  • Confirm the dependency name returned by your config validation method matches the resource name on the machine exactly (names are compared as strings, so case and spelling must match).
  • Verify the depended-on resource exists and is configured correctly.
  • Check for circular dependencies – if A depends on B and B depends on A, both will fail to start. Check the LOGS tab for “circular dependency” errors.
Readings returning None or nil
  • Add logging inside your GetReadings implementation to see what data your source returns.
  • GetReadings must return a non-nil map. If it returns nil (Go) or None (Python), viam-server treats this as an error.
  • Check network connectivity from the machine if your sensor reads from an external source.
Module not restarting after code changes

viam-server does not watch your module’s source files or binary for changes. To deploy changes:

  • Use viam module reload-local --part-id <id> to rebuild and redeploy.
  • Use viam module restart --part-id <id> to restart without rebuilding.

If reload-local fails:

  • “no build command” – Your meta.json is missing a build.build field. Add the path to your build script (for example, "build": "./build.sh").
  • “could not find module ID” – Run the command from the directory containing meta.json, or use --home <path> to specify the module directory.
  • PermissionDenied errors – Try --home $HOME to ensure the CLI can locate the module metadata.
Data capture not recording readings

Data capture requires both the data management service and a per-resource capture configuration:

  • Verify the data management service is configured and does not have capture_disabled set to true.
  • Verify your sensor component has a data capture configuration with capture_frequency_hz greater than 0.
  • Check that GetReadings returns a valid, non-nil map.
  • If the capture frequency is very low, you may need to wait longer to see data appear on the Data page.

What’s Next

  • Write a Logic Module – write a module that monitors sensors, coordinates components, or runs automation logic.
  • Deploy a Module – package your module and upload it to the Viam registry for distribution.
  • Module Reference – complete reference for meta.json, CLI commands, environment variables, and resource interfaces.