Phase 6: Inline module
This phase is optional. The previous phase already gave you a complete pick-and-place loop that runs from your own laptop: detection, planned motion, and a reliable place. This phase packages that same loop as a module so it runs on the robot directly, with no laptop connection required once it is deployed.
Why use a module
A module packages a resource so viam-server can run it directly, the same way the ufactory and realsense modules you added earlier run their drivers. Modules are the unit Viam shares and deploys: they live in a registry where they can be reused across machines, they carry versions so you can roll a deployment forward or back, and they build for each platform your fleet runs. Packaging your pick-and-place loop as a module puts your pick-and-place code on that same footing as any driver.
For your own robot logic, reach for a module when one of these is true for your setup:
- The cycle has to keep running after you close your laptop or walk away.
- The cycle has to restart on its own if it crashes or the robot reboots.
- You want to deploy an updated version to the robot without pushing code from a laptop by hand.
- You want the cycle to run on a schedule instead of a manual trigger.
If none of those apply, stop here. You have already built the thing this workshop set out to teach.
This phase builds an inline module: the Viam app hosts the code and builds it for you in the cloud, with the source in an editor in your browser. A conventional module lives in its own Git repository that you build and upload yourself, and it is the better choice for code you share across many machines or maintain as a team. Inline is the faster path for a single machine’s own logic, so it is what you use here; everything you learn about the module lifecycle applies to both.
What changes when you package it
The detection, the pose math, and the motion calls are the same pick-and-place logic, moved into a module’s lifecycle methods with no change to what they do. Only the wiring around that logic changes, in a few places:
- No connection code necessary. The module is part of the machine, so authentication is no longer necessary.
- How the module gets its resources. A script calls
from_robotafter it connects; a module receives its resources through dependency injection instead. - Config-driven resources. Your local script uses hardcoded component names like “arm-1.” The module reads those names dynamically, which allows a module to be used across different machines that may have different naming conventions.
- How the logic gets triggered. A script runs top to bottom under
__main__; a module runs its logic behind ado_commandentry point that something else calls. - Hard constraints in class structure. For an inline module, the Viam app auto-generates the model identifier and class name, and you must not change them.
Create the module
On the Configure tab, click the + icon then Code. Choose a “Viam-hosted” module, click Proceed, and select Python as the language. The Viam app creates a new configured resource with an embedded code editor and a generated module skeleton.

Logic like this is typically modeled as a generic service: a resource that exposes no specialized API of its own, so you can put arbitrary logic behind it. The skeleton builds that service on the EasyResource mixin, a convenient base class from the Viam SDK that fills in the boilerplate every resource needs and lets you override only the parts you care about. Those parts are lifecycle methods: functions the module framework calls at set points, such as validate_config when the config is checked, new when the resource starts, and close when it shuts down. One more method, do_command, is the generic service’s entry point: because the service has no typed API, do_command is how a caller runs the actions it exposes.
Module identification on the registry
One value in that skeleton you should not change is the class’s MODEL: a three-part namespace:module-name:model-name identifier, such as your-org:your-module:pick-and-place, that the Viam app generates when it creates the module. Together with the class name, it names your model uniquely on the registry, and the platform matches your code to its registry registration by exactly these values. Leave the MODEL and the class name as generated; changing either breaks that match, and the module fails to build.
Over the next two sections you move your pick-and-place code into this skeleton: the typed resource handles become dependency injection through new, and the detection, pose math, and motion calls gather behind the do_command entry point. Once all the code is in place, you can save and build your code block, then run it on the robot.
Dependency injection
The same resources reach your code differently in a script versus a module:
Script (Phases 4 and 5)
connect → RobotClient.at_address(...)
arm = Arm.from_robot(machine, "arm-1")
Module (Phase 6) (same logic, different wiring)
validate_config() declares dependencies from the config attributes
new(config, deps) looks up each dependency by its configured name
A script builds its resource handles once, right after it connects, by calling Arm.from_robot(machine, "arm-1") and similar for each resource it needs. A module does not connect to itself, so it cannot call from_robot the same way. Instead, the module framework hands your module its dependencies.
Two lifecycle methods carry this pattern:
validate_configruns before your module starts and declares which resources it depends on, soviam-serverknows to hold your module back until those resources are online, the same dependency ordering you already saw betweengripper-1andarm-1in Phase 2. It reads the module’s own config attributes, where each attribute value is the name of a resource on the machine, and returns those names as the required dependencies.newreceives the resolved dependencies as a mapping keyed by resource name, and this is where you build the typed handles your pick-and-place logic calls.newis theEasyResourceclassmethod that constructs your resource; it reads the same config attributes to look each dependency up by its configured name.
A small illustrative sketch of both methods:
from viam.components.arm import Arm
from viam.utils import struct_to_dict
@classmethod
def validate_config(cls, config):
attrs = struct_to_dict(config.attributes)
required_deps = []
if "arm" not in attrs or not attrs["arm"]:
raise ValueError("attribute 'arm' (non-empty string) is required")
required_deps.append(attrs["arm"])
# ...same for gripper, camera, "home-pose", "travel-pose", "place-pose", vision...
required_deps.append("builtin") # motion service
return required_deps, []
@classmethod
def new(cls, config, dependencies):
self = super().new(config, dependencies)
attrs = struct_to_dict(config.attributes)
self.arm = dependencies[Arm.get_resource_name(attrs["arm"])]
# ...look up gripper, camera, the pose switches, and vision the same way...
return self
Keep the rest of your new close to this shape: look up each resource your existing Python script used by its configured attribute name, and store it on self so your pick-and-place logic can call it later. Note that the derived poses for approach and grasp do not need to be declared in new or validate config.
The motion service is injected the same way. validate_config declares it as "builtin", and new retrieves it with dependencies[MotionClient.get_resource_name("builtin")] (from viam.services.motion import MotionClient), so motion.move works straight from the injected dependencies.
Same resource names, different retrieval
Compare how you got the arm handle in the Python script against how you get it inside the module:
- Local script (Phases 4-5):
arm = Arm.from_robot(machine, "arm-1"). - Module:
arm = dependencies[Arm.get_resource_name(attrs["arm"])].
The resource name is still "arm-1" in both. In the module you set it once as the module’s arm config attribute, which the operator points at arm-1, and attrs["arm"] reads it back. The same is true of the gripper, camera, every pose switch, the vision service, and the motion service. Only the retrieval mechanism changes, from calling from_robot on a connected machine handle to looking the resource up in the dependencies mapping new received.
Trigger the module with do_command
With dependencies wired up, assemble your pick-and-place logic into a single run_pick_cycle method on the module, the same detection, pose math, and motion calls, unchanged. What differs is how that method gets triggered.
Assembled onto the module, using the dependencies you looked up in new, run_pick_cycle looks like this:
async def run_pick_cycle(self) -> bool:
"""Run one detect-pick-place cycle. Returns False if no object was detected, True on a completed cycle."""
# 1. Observe from home so the wrist-mounted camera frame is in a known position.
await self.home_pose.set_position(2)
# 2. Detect. vision-segment fuses the 2D shape detections with depth into
# 3D objects, each with a point cloud and a label.
objects = await self.vision.get_object_point_clouds(self.camera_name)
if not objects:
print("No objects detected")
return False
# Largest object by point-cloud byte size (a proxy for point count).
# point_cloud is raw PCD bytes, so use len(point_cloud), not .size.
obj = max(objects, key=lambda o: len(o.point_cloud))
geometry = obj.geometries.geometries[0]
label = geometry.label
print(f"Detected: {label}")
# 3. Derive the approach pose from the object center, in the camera
# frame. cam-1 is wrist-mounted, but the approach move is resolved
# from the camera frame while the arm is still at home, so it lands
# accurately above the block.
obj_in_cam = PoseInFrame(reference_frame=self.camera_name, pose=geometry.center)
approach_pose = offset_pose(obj_in_cam.pose, APPROACH_MM)
# 4. Pick: move above in the camera frame, open, then descend the
# remaining distance straight down in the gripper's own frame, grab,
# lift. Descending relative to the gripper avoids the wrist-mounted
# camera frame shifting once the arm moves for the approach.
await self.motion.move(
self.gripper_name,
PoseInFrame(reference_frame=self.camera_name, pose=approach_pose),
)
await self.gripper.open()
await asyncio.sleep(SETTLE_S)
# Move the remaining distance from the approach pose (as a positive value).
grasp_distance = (APPROACH_MM - GRIPPER_LENGTH_MM) * -1
await self.motion.move(
self.gripper_name,
PoseInFrame(
reference_frame=self.gripper_name,
pose=Pose(x=0, y=0, z=grasp_distance, o_x=0, o_y=0, o_z=1, theta=0),
),
)
await self.gripper.grab()
await asyncio.sleep(SETTLE_S)
# 5. Place: lift to the safe carrying height, drop at the saved bin pose.
await self.travel_pose.set_position(2)
await self.place_pose.set_position(2)
await self.gripper.open()
await self.home_pose.set_position(2)
return True
You trigger the module through do_command. Because a generic service has no typed API of its own, do_command is its entry point: the method you dispatch on to run the actions your service exposes. A small illustrative sketch:
async def do_command(self, command, *, timeout=None, **kwargs):
if command.get("action") == "pick_cycle":
success = await self.run_pick_cycle()
return {"success": success}
return {}
Module build feedback loop
Saving an inline Python module triggers a cloud build that takes about a minute. Give it that minute rather than assuming the save failed.
With the code in place, save the module. The Viam app packages it and deploys it to the machine, and the Logs tab shows the build progress the same way it showed module downloads back in Phase 2.

Checkpoint
From the Control tab, find your module’s test card and send a command such as {"action": "pick_cycle"} to run one full pick-and-place cycle on demand, the same cycle you watched run from your script, now running on the robot instead of your personal computer. To compare your module against a finished one, read the complete module-reference.py in the companion repo.

Checkpoint
do_command trigger runs one complete pick-and-place cycle: detection, approach, grasp, travel, and place, ending with a block in the bin.Where you landed
You now have the same pick-and-place loop running two ways: as a script you control from your personal computer, and as a module that keeps running on the robot. Head to the wrap-up to review everything you built and where to take it next.
Was this page helpful?
Glad to hear it! If you have any other feedback please let us know:
We're sorry about that. To help us improve, please tell us what we can do better:
Thank you!