diff --git a/.htmltest.yml b/.htmltest.yml
index c5fd7ec286..0d3899f7f1 100644
--- a/.htmltest.yml
+++ b/.htmltest.yml
@@ -33,6 +33,7 @@ IgnoreURLs:
- "twitter.com"
- "github.com/viamrobotics/docs"
- "github.com/viam-devrel/pick-and-place"
+ - "github.com/viam-devrel/mini-palletizer"
- "openai.com"
- "espressif.com"
- "pinout.xyz"
diff --git a/assets/tutorials/so-arm101-palletizing/diagram-cell-layout.svg b/assets/tutorials/so-arm101-palletizing/diagram-cell-layout.svg
new file mode 100644
index 0000000000..7ce90586e9
--- /dev/null
+++ b/assets/tutorials/so-arm101-palletizing/diagram-cell-layout.svg
@@ -0,0 +1,104 @@
+
diff --git a/assets/tutorials/so-arm101-palletizing/grid-iso.png b/assets/tutorials/so-arm101-palletizing/grid-iso.png
new file mode 100644
index 0000000000..494ff4e2ac
Binary files /dev/null and b/assets/tutorials/so-arm101-palletizing/grid-iso.png differ
diff --git a/docs/tutorials/so-arm101-palletizing/01-platform-mental-model.md b/docs/tutorials/so-arm101-palletizing/01-platform-mental-model.md
new file mode 100644
index 0000000000..b2251e228f
--- /dev/null
+++ b/docs/tutorials/so-arm101-palletizing/01-platform-mental-model.md
@@ -0,0 +1,79 @@
+---
+title: "Phase 1: The platform and the cell"
+linkTitle: "1. Platform mental model"
+type: "docs"
+slug: "platform-mental-model"
+weight: 10
+description: "How your computer and Viam fit together, and the three robotics concepts this workshop is built on."
+workshop: "so-arm101-palletizing"
+toc_hide: true
+phase: 1
+phase_total: 6
+next: "/tutorials/so-arm101-palletizing/configure-the-arm/"
+languages: ["python"]
+---
+
+Before you configure anything, this phase gives you a mental model of the cell you are about to build and the platform it runs on. Everything after this leans on the ideas introduced here.
+
+## What you'll build
+
+You are building a miniature palletizing cell. An SO-ARM101 arm picks cubes, one at a time, from a staging spot and stacks them on a small pallet: two layers of four, eight cubes total. The arm and its gripper are the hardware; a set of hand-taught poses and, later, your own Python code, are what turn that hardware into a working pack routine.
+
+
+
+{{}}
+
+## The three layers
+
+A Viam machine is made of three layers that each do one job:
+
+```text
+Viam cloud app
+ └─ machine configuration (the source of truth)
+ │
+ │ viam-server pulls config
+ ▼
+Your personal computer
+ └─ viam-server drives the arm and gripper, exposes the control API
+ ▲
+ │ control API calls
+ │
+Your Python script
+```
+
+- **The Viam cloud app** is the source of truth for configuration. When you add a component, change an attribute, or configure a service, you are editing a JSON document stored in the cloud. The app never controls your robot directly; it describes what should run.
+- **viam-server** runs on your computer. It pulls the configuration from the cloud app, starts every component and service that configuration describes, and exposes them over a control API. This is the layer that talks to the arm: the SO-ARM101 connects to your computer over a USB serial cable, and `viam-server` drives the arm's motors through that connection.
+- **Your Python script** connects to `viam-server` and calls that control API to move the arm and read its position. You write it later in the workshop.
+
+Open your machine's page in the Viam app now and find the status indicator that shows the machine is live. That indicator confirms `viam-server` is running on your computer and connected to the cloud app.
+
+## How your code connects
+
+When a Python script imports the Viam SDK and connects to your machine, the connection goes to `viam-server`, not to the cloud app. The cloud app helps your script locate the machine and authenticate, but once the connection is established, every control API call, moving the arm and reading its position, goes directly to `viam-server`. You will write exactly this kind of script later in the workshop.
+
+Open the **CONNECT** tab on your machine's page and look at the code sample it generates. It contains your machine's address and an API key and ID pair, the things any script needs to authenticate and reach `viam-server`.
+
+## Components and services
+
+Everything a Viam machine does is modeled as a resource, and resources split into components and services.
+
+- The arm and the gripper are **components**: each one wraps a piece of physical hardware and exposes a standard API for it. An arm moves to a pose; a gripper opens and closes.
+- The **motion service** is a service: software that plans how the arm should move, rather than hardware you can point at. It is a **builtin** service, so it ships with `viam-server` and needs no configuration; you just call its API. The arm and gripper components, by contrast, come from a module that `viam-server` downloads when you configure them in Phase 2.
+
+## Three robotics concepts to learn
+
+Three concepts carry the whole workshop. This section only previews them; you will work with each one directly in a later phase.
+
+The **frame system** answers "where is everything around the robot, and how is it related?" It tracks the position of every component and object in the cell, and the parent-child relationships between them, all traced back to the world origin. You teach the two key positions to the arm by hand in Phase 3. See [Frame system](/motion-planning/frame-system/overview/) for the full picture.
+
+**Motion planning** answers "how does the arm get there?" Given a target pose, the motion service works out a path of joint movements that reaches it without colliding with anything.
+
+**WorldState** answers "what is in the world around the robot?" On each move, it tells the motion service what to account for: **obstacles**, the things to avoid, and **transforms**, the things that move with the arm, like the gripper and any cube it is holding. In this workshop, WorldState grows as you pack: each cube already on the pallet is an obstacle to route around, and the cube in the gripper rides along as a transform. See [Obstacles and WorldState](/motion-planning/obstacles/) for more.
+
+{{< checkpoint >}}
+Open your machine in the Viam app and confirm the green **Live** indicator, so you know `viam-server` is running and reachable before you configure anything. You should also be able to say, in your own words, what the frame system, the motion service, and WorldState each do, since every later phase builds on them. If the machine is not Live, start `viam-server` on your computer before continuing.
+{{< /checkpoint >}}
+
+With the mental model in place, [Phase 2](/tutorials/so-arm101-palletizing/configure-the-arm/) is where you add the arm and gripper, verify them, and place them in the frame system.
+
+{{< workshop-nav >}}
diff --git a/docs/tutorials/so-arm101-palletizing/02-configure-the-arm.md b/docs/tutorials/so-arm101-palletizing/02-configure-the-arm.md
new file mode 100644
index 0000000000..4556aa43a4
--- /dev/null
+++ b/docs/tutorials/so-arm101-palletizing/02-configure-the-arm.md
@@ -0,0 +1,138 @@
+---
+title: "Phase 2: Configure the SO-ARM101"
+linkTitle: "2. Configure the arm"
+type: "docs"
+slug: "configure-the-arm"
+weight: 20
+description: "Add the arm and gripper with the discovery service, verify them with test cards, and place the arm in the frame system."
+workshop: "so-arm101-palletizing"
+toc_hide: true
+phase: 2
+phase_total: 6
+prev: "/tutorials/so-arm101-palletizing/platform-mental-model/"
+next: "/tutorials/so-arm101-palletizing/teach-the-cell/"
+languages: ["python"]
+---
+
+In this phase you configure the arm and gripper, verify them with their test cards on the **CONTROL** tab, then place both in the frame system so the rest of the platform knows where they sit.
+
+## Add the discovery service
+
+Rather than typing the arm and gripper configs by hand, start with the SO-ARM101 module's **discovery service**. A discovery service reports the hardware attached to a machine and suggests configurations for it, so you configure the right components without hunting for serial ports or attribute names by hand. See [Discovery service](/reference/services/discovery/) for the general pattern.
+
+On the **CONFIGURE** tab, click the **+** icon and select **Blocks**. Search for `so101` and select the `so101/discovery` result. Leave its name as the default and save the config. Saving is the moment `viam-server` downloads the SO-ARM101 module; the arm and gripper models you add later in this phase come from that same module, so the download happens only once.
+
+
+
+Before you open the discovery service's test panel, know what it is looking for: the serial port your SO-ARM101 is connected to over USB.
+
+- On Linux, the port shows up as `/dev/ttyUSB0` or `/dev/ttyACM0`.
+- On macOS, look under `/dev/tty.*` for a name containing `usbmodem` or `usbserial`.
+
+Open the discovery service's **TEST** panel. It scans for a connected SO-ARM101 and, if it finds one, returns ready-made configuration snippets for the arm and the gripper, with the detected `port` already filled in.
+
+
+
+With the arm connected and powered, select **Add component** next to each suggested snippet to create the arm and gripper components from it. If discovery does not find your arm, confirm the USB cable is connected and that no other program is holding the serial port open, then retry.
+
+{{< alert title="Discovery has done its job" color="note" >}}
+Like the config it suggests, the discovery service is not part of the pack sequence you build later in this workshop. Leave it in place if you expect to re-discover hardware, or remove it once the arm and gripper are configured.
+{{< /alert >}}
+
+## Add the arm component
+
+If you used discovery, confirm the arm component it created has one attribute, `port`, set to your arm's serial port:
+
+```json
+{
+ "port": "/dev/ttyUSB0"
+}
+```
+
+If you are configuring the arm by hand instead, click the **+** icon, select **Blocks**, search for `so101`, and select the `so101/arm` result. Name it `arm`, paste the attribute above with your own port, and save.
+
+Open the **CONTROL** tab and find the arm's test card. Test cards call the same API your Python code calls later in this workshop; jogging a joint here is a real API call that moves the hardware. Move one joint slider a small amount and press **Execute**, then watch the physical arm turn.
+
+
+
+{{< alert title="The arm is about to move" color="caution" >}}
+Keep the workspace clear and change one joint a small amount at a time. Large or combined joint moves can drive the arm into the table or itself.
+{{< /alert >}}
+
+{{< checkpoint >}}
+Moving a joint slider and pressing **Execute** on the arm's test card moves the physical arm. If nothing moves, confirm the arm shows online in the CONFIGURE tab and check the LOGS tab for a serial connection error.
+{{< /checkpoint >}}
+
+## Add the gripper component
+
+The gripper is the SO-ARM101's sixth servo, on the same serial bus as the other five, so its config looks almost identical to the arm's: the same `port` attribute, pointed at the same serial port.
+
+If you used discovery, confirm the gripper component it created carries this same port:
+
+```json
+{
+ "port": "/dev/ttyUSB0"
+}
+```
+
+If you are configuring the gripper by hand, click the **+** icon, select **Blocks**, search for `so101`, and select the `so101/gripper` result. Name it `gripper`, set `port` to the same value as the arm's, and save.
+
+Open the gripper's test card on the **CONTROL** tab. Press **Open** and watch the jaw open, then press **Grab** and watch it close.
+
+
+
+{{< checkpoint >}}
+Pressing **Open** and **Grab** on the gripper's test card opens and closes the physical jaw. If nothing moves, confirm the gripper shows online in the CONFIGURE tab and that its `port` matches the arm's.
+{{< /checkpoint >}}
+
+## Place the arm and gripper in the frame system
+
+Adding the arm and gripper tells `viam-server` how to talk to them, but not where they sit in the cell. As Phase 1 covered, the frame system answers that question for every component in the workshop: a frame places a component relative to a parent, and every frame traces back to `world`. See [Frame system](/motion-planning/frame-system/overview/) for the general concept.
+
+The **world frame** is the fixed reference point for the whole cell, the origin that every other position is measured from. Every frame in the system traces back to it. Placing the arm's frame with parent `world` and translation `(0, 0, 0)` puts the arm's base exactly at that origin.
+
+That choice matters for the next phase. Because the arm's base is the world origin, any position you read from the arm, including the poses you capture by hand in Phase 3, is already a position in the world frame, with no conversion needed. You use the arm itself as your measuring tool, and what it reports is directly usable.
+
+Open the arm's card on the **CONFIGURE** tab and select **Frame**. The default frame already describes parent `world`, translation `(0, 0, 0)`, and no rotation, so you can leave the defaults and save.
+
+```json
+{
+ "parent": "world",
+ "translation": { "x": 0, "y": 0, "z": 0 },
+ "orientation": {
+ "type": "ov_degrees",
+ "value": { "x": 0, "y": 0, "z": 1, "th": 0 }
+ }
+}
+```
+
+The gripper is a separate component with its own collision geometry, so it needs its own frame to place that geometry in the cell. You attach it to the arm. Open the gripper's card, select **Frame**, set its parent to `arm`, and leave the translation and rotation at zero:
+
+```json
+{
+ "parent": "arm",
+ "translation": { "x": 0, "y": 0, "z": 0 },
+ "orientation": {
+ "type": "ov_degrees",
+ "value": { "x": 0, "y": 0, "z": 1, "th": 0 }
+ }
+}
+```
+
+Attaching the gripper to the arm places its shape in the cell: the 3D scene draws the gripper on the end of the arm, and the motion service accounts for the gripper's shape when it plans, so it keeps the jaws clear of obstacles.
+
+## See it in the 3D scene
+
+Open the **3D scene** tab. The arm renders using the kinematics built into the `so101/arm` model, sitting at the frame you configured, with the gripper attached at its end point. This is the same view you will return to throughout the rest of the workshop to watch the pack sequence run.
+
+
+
+Jog a joint on the arm's test card again and watch the 3D scene update alongside the physical arm.
+
+{{< checkpoint >}}
+The 3D scene shows the SO-ARM101 model, gripper included, at its current joint positions, and it updates as you jog a joint from the CONTROL tab. If the gripper is missing or floating away from the arm, recheck that its frame parent is set to `arm` with zero translation. If the scene is blank, confirm the arm's frame saved and that the arm shows online in the CONFIGURE tab.
+{{< /checkpoint >}}
+
+With the arm and gripper configured, verified, and placed in the frame system, you are ready for [Phase 3](/tutorials/so-arm101-palletizing/teach-the-cell/), where you map the physical cell, the staging spot and the pallet, into the arm's frame by hand.
+
+{{< workshop-nav >}}
diff --git a/docs/tutorials/so-arm101-palletizing/03-teach-the-cell.md b/docs/tutorials/so-arm101-palletizing/03-teach-the-cell.md
new file mode 100644
index 0000000000..a1c0f3b580
--- /dev/null
+++ b/docs/tutorials/so-arm101-palletizing/03-teach-the-cell.md
@@ -0,0 +1,97 @@
+---
+title: "Phase 3: Teach the cell by hand"
+linkTitle: "3. Teach the cell"
+type: "docs"
+slug: "teach-the-cell"
+weight: 30
+description: "Move the arm by hand with torque disabled, read two anchor poses off its test card, and compute the pallet grid from them."
+workshop: "so-arm101-palletizing"
+toc_hide: true
+phase: 3
+phase_total: 6
+prev: "/tutorials/so-arm101-palletizing/configure-the-arm/"
+next: "/tutorials/so-arm101-palletizing/pack-from-python/"
+languages: ["python"]
+---
+
+In this phase you map the physical cell into the arm's frame. Nothing in the cell is pre-measured: you find where the staging spot and the pallet actually sit, expressed in the arm's own coordinate frame, by moving the arm there yourself and reading its position back from the Viam app. You capture two anchor poses this way; the code you write in Phase 4 computes the rest of the pallet grid from them.
+
+{{< alert title="The arm goes limp" color="caution" >}}
+Disabling torque lets you move the arm by hand, but it also means the arm no longer holds its position against gravity. It drops as soon as you disable torque, and stays free to fall until you re-enable it. Support the arm with one hand while torque is off, clear the workspace and cubes from underneath it, and re-enable torque before you send any motion command.
+{{< /alert >}}
+
+## Why teach by hand
+
+This cell needs eight target poses, one per cube, and teaching all eight by hand would be slow and error-prone. Instead, you capture just two anchors: the staging spot, where you hand-feed each cube, and the pallet's origin corner, the bottom-layer cell at grid position [0, 0]. Every other pallet position is a fixed offset from that origin corner, so once you know the origin and the grid spacing, you compute the remaining seven poses instead of teaching them individually.
+
+## Place the pallet mat and staging marker
+
+Before you capture anything, set the printed markers in place. Print the cube-and-pallet template from the [companion project](https://github.com/viam-devrel/mini-palletizer) at 100% scale and cut out the pallet mat and the staging square. (If you are using your own cubes, you still want the mat and staging marker for consistent positions.)
+
+- Set the **pallet mat** on a flat surface within the arm's reach. Line up the mat's **x** and **y** arrows with the arm's x and y axes, which you can see on the world frame in the **3D scene** tab. The mat's **origin** square is the pallet corner you will teach.
+- Set the **staging square** to one side of the pallet, also within reach. This is where you hand-feed each cube.
+- **Tape both down.** They must not move while you teach poses or while the arm runs the pack later, or the cubes will miss their marks. Once they are fixed, leave them put for the rest of the workshop.
+
+With the markers fixed, you teach the arm two spots: the origin square on the mat and the staging square.
+
+
+
+## Disable torque
+
+The standard arm API covers moving the arm and reading its position, but hardware often has extra capabilities that do not fit those standard methods. Viam exposes those through **`DoCommand`**, a general-purpose command channel a module can use to accept commands specific to its hardware. The SO-ARM101 module uses it for a `set_torque` command that turns the servos' holding torque on and off.
+
+On the arm's test card on the **CONTROL** tab, open the DoCommand box and send:
+
+```json
+{
+ "command": "set_torque",
+ "enable": false
+}
+```
+
+Once the command succeeds, the arm's joints go slack and you can move it by hand.
+
+
+
+## Read the arm's position from the app
+
+The arm's test card on the **CONTROL** tab shows its current **end position**: the x, y, and z of the arm's end point, in millimeters, plus an orientation. As you move the arm by hand with torque disabled, that readout updates to track it. Because you placed the arm's base at the world origin in Phase 2, this end position is also a position in the world frame.
+
+You position the arm so the gripper's jaws sit where you want them, then read the end position off the card. Because the gripper is rigidly attached, driving the arm's end point back to that same pose later returns the jaws to the same spot.
+
+
+
+## Capture the staging pose
+
+With torque disabled, gently guide the gripper to the staging spot, the place where you will set down one cube at the start of every pick cycle in later phases. Hold the arm steady once it is in position, then read the **end position** off the arm's test card and record the x, y, and z. This is your staging pose. Move the arm slightly and watch the readout change, so you know it is tracking the live position, then guide it back and re-read if needed.
+
+
+
+## Capture the pallet origin corner
+
+Still with torque disabled, guide the gripper to the bottom-layer corner of the pallet, cell [0, 0], the corner you treat as the origin of the pallet grid. Read the **end position** again and record the x, y, and z. This is your pallet origin pose.
+
+## Re-enable torque
+
+Send the same `DoCommand` with `enable` flipped to `true`:
+
+```json
+{
+ "command": "set_torque",
+ "enable": true
+}
+```
+
+The arm's joints stiffen and it holds its current position. Confirm this by letting go of the arm; it should stay put instead of drooping.
+
+## Save your anchors
+
+Write down the two poses you just read, staging and pallet origin, each as the x, y, and z from the arm's test card. Keep this note handy: in Phase 4 you paste these numbers into the companion project's `helpers.py`, into the `STAGING_POSE` and `PALLET_ORIGIN` constants that `palletizer.py` reads. From there, `palletizer.py` passes `PALLET_ORIGIN` into `helpers.grid` to get all eight target poses, and uses `STAGING_POSE` as the fixed pick location for every cycle.
+
+{{< checkpoint >}}
+With torque disabled, the arm's end position on its test card changes as you move the arm by hand, confirming the readout tracks the physical arm. After you re-enable torque, the arm holds its pose and does not drift when you let go. You have two recorded poses, staging and pallet origin, written down and ready to carry into Phase 4. If the readout does not change as you move the arm, confirm torque is actually disabled; if the arm still droops after re-enabling torque, resend the `set_torque` command with `enable` set to `true` and check the LOGS tab for a serial error.
+{{< /checkpoint >}}
+
+With your two anchor poses recorded, [Phase 4](/tutorials/so-arm101-palletizing/pack-from-python/) is where you write the Python that reads them back and drives the arm through a pick-and-place pack.
+
+{{< workshop-nav >}}
diff --git a/docs/tutorials/so-arm101-palletizing/04-pack-from-python.md b/docs/tutorials/so-arm101-palletizing/04-pack-from-python.md
new file mode 100644
index 0000000000..4e08ab7158
--- /dev/null
+++ b/docs/tutorials/so-arm101-palletizing/04-pack-from-python.md
@@ -0,0 +1,275 @@
+---
+title: "Phase 4: Pack from Python"
+linkTitle: "4. Pack from Python"
+type: "docs"
+slug: "pack-from-python"
+weight: 40
+description: "Build palletizer.py method by method and drive a static bottom-layer pack from your own code."
+workshop: "so-arm101-palletizing"
+toc_hide: true
+phase: 4
+phase_total: 6
+prev: "/tutorials/so-arm101-palletizing/teach-the-cell/"
+next: "/tutorials/so-arm101-palletizing/avoid-placed-cubes/"
+languages: ["python"]
+---
+
+In this phase you write `palletizer.py`, a Python script that reads back the two anchor poses from Phase 3 and drives the arm through a packing routine for each of the four bottom-layer cells. Getting through this phase is milestone one: you drive the arm through a static pack from your own code.
+
+## Set up the companion project
+
+Clone the workshop's companion repository and work from it for the rest of this phase:
+
+```sh
+git clone https://github.com/viam-devrel/mini-palletizer.git
+cd mini-palletizer
+```
+
+The project ships with a `pyproject.toml`, so `uv run` resolves and installs the Viam Python SDK (software development kit) for you the first time you run any script in the directory. If you are not using `uv`, install `viam-sdk` yourself and use `python3` instead.
+
+Open the machine's **CONNECT** tab in the Viam app, select **Python SDK**, toggle **Include API key**, and copy the machine address and the application programming interface (API) key and key ID pair it shows you. `helpers.py` reads these from constants near the top of the file; paste your own values in before you run anything.
+
+
+
+Open `helpers.py` and set the two constants `STAGING_POSE` and `PALLET_ORIGIN` to the two poses you captured by hand in Phase 3. `palletizer.py` reads both from `helpers.py`, so this is where the numbers you recorded become the code's picking and stacking targets.
+
+`helpers.py` is provided for you as part of the companion project. You write `palletizer.py` yourself in this phase, starting from an empty file in the same directory.
+
+## What the helpers give you
+
+Start from [helpers.py](https://github.com/viam-devrel/mini-palletizer/blob/main/helpers.py); import it rather than rewriting the connection and grid math. It gives you:
+
+- `helpers.connect()`, an `async` function that returns a connected `RobotClient`.
+- The arm's resource name (`helpers.ARM`), which you hand to the motion service, plus the gripper and motion-service names (`helpers.GRIPPER`, `helpers.MOTION`), which you pass to `from_robot`. All three name resources configured in Phase 2.
+- `down_pose(x, y, z)`, which returns a `Pose` at that position with the tool pointing straight down.
+- `helpers.grid(origin, pitch, cube)`, which expands one origin corner into the eight target poses of a two-layer, four-cell pallet (explained in the next section).
+- `helpers.STAGING_POSE` and `helpers.PALLET_ORIGIN`, the two anchor poses you captured by hand in Phase 3.
+
+`palletizer.py` imports these names and composes them into motion calls.
+
+## The pallet grid
+
+You captured one pallet corner in Phase 3. The other seven target poses follow from two constants: the center-to-center spacing between cells, and the cube's size, which sets the gap between the two stacked layers.
+
+```python
+PITCH = 30 # mm, center-to-center spacing between adjacent pallet cells
+CUBE = 20 # mm, cube side length, and the z offset between layers
+```
+
+The four bottom-layer cells are the origin corner plus every combination of `0` and `PITCH` in x and y. The top layer repeats those four positions one `CUBE` higher in z, giving eight target poses in all: four on the pallet and four stacked directly on top.
+
+
+
+{{}}
+
+`helpers.grid` builds that list of eight poses for you from the origin corner you captured:
+
+```python
+def grid(origin, pitch, cube):
+ """Return the eight target poses for a two-layer, four-cell pallet,
+ given the bottom-layer origin corner (cell [0, 0])."""
+ bottom = [
+ Pose(x=origin.x + dx, y=origin.y + dy, z=origin.z)
+ for dx in (0, pitch)
+ for dy in (0, pitch)
+ ]
+ top = [Pose(x=p.x, y=p.y, z=p.z + cube) for p in bottom]
+ return bottom + top
+```
+
+These are positions only. You apply a straight-down tool orientation to each one with the `down_pose` helper before sending it to the motion service, which the `place` method does below.
+
+The staging pose is not part of this grid. It stays a single fixed pose for the whole routine: you hand-feed one cube to that same spot at the start of every cycle, and the arm always picks from there.
+
+## Build palletizer.py
+
+Build the file up one method at a time. Each piece below is small enough to test on its own before you move to the next.
+
+### The class and connection
+
+Start with the imports, the constants this phase adds, and a `Palletizer` class that holds a motion client and a gripper handle:
+
+```python
+import asyncio
+import sys
+
+from viam.components.gripper import Gripper
+from viam.services.motion import MotionClient
+from viam.proto.common import Pose, PoseInFrame
+
+import helpers
+from helpers import down_pose
+
+PITCH = 30 # mm, center-to-center spacing between adjacent pallet cells
+CUBE = 20 # mm, cube side length, and the z offset between layers
+APPROACH = 40 # mm, hover height above a pose before descending
+GRASP_DEPTH = 5 # mm, how far the gripper descends past the cube top to close on it
+
+
+class Palletizer:
+ def __init__(self, robot):
+ self.robot = robot
+ self.motion = MotionClient.from_robot(robot, helpers.MOTION)
+ self.gripper = Gripper.from_robot(robot, helpers.GRIPPER)
+ self.placed = []
+```
+
+`PITCH` and `CUBE` are the constants from the pallet grid above. `APPROACH` and `GRASP_DEPTH` are new: `APPROACH` is how high above a target pose the arm hovers before descending, and `GRASP_DEPTH` is how far past a cube's top surface the gripper descends so its fingers close around the cube rather than skim its top. `self.placed` tracks which grid cells already hold a cube.
+
+There is nothing to run yet. This class only sets up handles; the next method makes the first move.
+
+### move_gripper
+
+Every move in this workshop reduces to one call: hand the motion service a destination pose and let it plan a path to the arm's fingertip. Add this method to the `Palletizer` class:
+
+```python
+ async def move_gripper(self, pose: Pose):
+ destination = PoseInFrame(reference_frame="world", pose=pose)
+ await self.motion.move(
+ component_name=helpers.ARM,
+ destination=destination,
+ world_state=None,
+ )
+```
+
+Name the arm, `helpers.ARM`. The motion service drives the arm's end point, where the gripper mounts, to `pose`. The anchor poses you taught in Phase 3 were recorded at that same end point with the gripper's jaws in position, so replaying them returns the jaws to where you taught them. The gripper, attached to the arm in Phase 2, rides along, and the planner accounts for its shape. `world_state=None` because this phase has no obstacles to avoid yet; Phase 5 adds them.
+
+Add a small `move` method to the same class to smoke-test this before building `pick` and `place`:
+
+```python
+ async def move(self):
+ """Send the gripper to a safe pose, pointing straight down."""
+ await self.move_gripper(down_pose(200, 0, 150))
+```
+
+You test this once `main` is in place, at the end of this section.
+
+### pick
+
+`pick` reads the fixed staging pose, hovers above it, descends onto the cube, closes the gripper, and lifts back clear. Add it to the `Palletizer` class:
+
+```python
+ async def pick(self):
+ """Pick the cube waiting on the staging spot and lift it clear."""
+ staging = helpers.STAGING_POSE
+ hover = down_pose(staging.x, staging.y, staging.z + APPROACH)
+ grasp = down_pose(staging.x, staging.y, staging.z - GRASP_DEPTH)
+ await self.move_gripper(hover)
+ await self.move_gripper(grasp)
+ await self.gripper.grab()
+ await self.move_gripper(hover)
+```
+
+The staging spot is a single fixed pose, and you hand-feed one cube to it before every call to `pick`. Hovering above the staging pose first, then descending straight down, keeps the approach vertical instead of dragging the gripper sideways into a cube that is already sitting there. Note the grasp target is `staging.z - GRASP_DEPTH`, a few millimeters below the taught height, so the fingers close around the cube rather than stopping level with its top.
+
+{{< checkpoint >}}
+Hand-feed a cube to the staging spot, then run `pick` on its own once `main` is wired up at the end of this section. The gripper hovers above the staging pose, descends, closes on the cube, and lifts it back to the hover height. If the fingers close on air, check that the cube is centered under `helpers.STAGING_POSE` and that `GRASP_DEPTH` is not so small that the fingers stop above the cube's top.
+{{< /checkpoint >}}
+
+### place
+
+`place` takes a grid cell index and sets the held cube down at that cell. Add it to the `Palletizer` class:
+
+```python
+ async def place(self, seq: int):
+ """Place the held cube into bottom-layer grid cell `seq`."""
+ target = helpers.grid(helpers.PALLET_ORIGIN, PITCH, CUBE)[seq]
+ hover = down_pose(target.x, target.y, target.z + APPROACH)
+ await self.move_gripper(hover)
+ await self.move_gripper(down_pose(target.x, target.y, target.z))
+ await self.gripper.open()
+ await self.move_gripper(hover)
+ self.placed.append(target)
+```
+
+`helpers.grid` returns all eight target poses, bottom layer followed by top layer; `seq` indexes into that list, and this phase only ever passes `0` through `3`, the four bottom-layer cells. The hover-then-descend pattern mirrors `pick`: transit above the cell first, then lower straight down, so the cube does not drag across neighboring cells on its way in. Unlike `pick`, `place` releases at exactly `target.z`, the taught cell height, rather than pressing below it, so the cube rests on the pallet surface at the height you captured.
+
+{{< checkpoint >}}
+`place` takes a `seq` argument, so there is no standalone step for it in `STEPS` yet; you verify it as the first cycle of `pack`, in the next section. When you run `pack`, the first cube is lowered into grid cell 0 and released. The cube should land inside the marked cell, not on top of an edge or a neighboring cell. If it lands off-center, recheck the pallet origin pose you captured in Phase 3, or confirm `PITCH` and `CUBE` match your measured cube spacing.
+{{< /checkpoint >}}
+
+### Pack the bottom layer
+
+With `pick` and `place` working individually, chain them into a loop that packs all four bottom-layer cells, pausing between cycles so you can hand-feed the next cube. Add this last method to the `Palletizer` class:
+
+```python
+ async def pack(self):
+ """Pack the bottom layer: one cube per grid cell, cells 0 through 3."""
+ for seq in range(4):
+ input(f"Place a cube on the staging spot, then press Enter (cell {seq})... ")
+ await self.pick()
+ await self.place(seq)
+ print(f"packed {len(self.placed)} cubes")
+```
+
+With the class complete, add the command-line plumbing at module level, outside the class, so `STEPS`, `main`, and the entry point sit at column 0:
+
+```python
+STEPS = {
+ "move": Palletizer.move,
+ "pick": Palletizer.pick,
+ "pack": Palletizer.pack,
+}
+
+
+async def main(verb):
+ robot = await helpers.connect()
+ palletizer = Palletizer(robot)
+ try:
+ step = STEPS.get(verb)
+ if step is None:
+ print(f"unknown step '{verb}'. steps: {', '.join(STEPS)}")
+ return
+ await step(palletizer)
+ finally:
+ await robot.close()
+
+
+if __name__ == "__main__":
+ verb = sys.argv[1] if len(sys.argv) > 1 else "pack"
+ asyncio.run(main(verb))
+```
+
+`STEPS` maps a command-line verb to a method, so you can run any single step by name instead of always running the whole pack. `pack` is the milestone-one path: it loops `seq` over the four bottom-layer cells, pausing for you to hand-feed a cube before each `pick`.
+
+## Run it
+
+Run each step with `uv run`, watching both the physical arm and the **3D scene** tab.
+
+First, confirm the connection and a basic move:
+
+```sh
+uv run palletizer.py move
+```
+
+{{< checkpoint >}}
+The gripper moves to a fixed pose, pointing straight down. If the script raises a connection error, recheck the machine address and API key in `helpers.py` against the CONNECT tab. If it raises a planning error, confirm `(200, 0, 150)` is inside your arm's reach; adjust the coordinates in `move` if your cell layout differs.
+{{< /checkpoint >}}
+
+Next, hand-feed one cube to the staging spot and run `pick`:
+
+```sh
+uv run palletizer.py pick
+```
+
+The arm should hover above the staging pose, descend, grab the cube, and lift it clear. Leave the cube held; you use it in the next step.
+
+Now run the full bottom-layer pack:
+
+```sh
+uv run palletizer.py pack
+```
+
+The script prompts you before each cycle. Hand-feed a cube to the staging spot, press Enter, and watch the arm pick it up and set it into the next grid cell. After the first cycle, confirm the cube landed inside grid cell 0, not on top of an edge or a neighboring cell, before you continue to the remaining three.
+
+
+
+{{< checkpoint >}}
+After four cycles, `pack` prints `packed 4 cubes` and the bottom layer of the pallet is full: four cubes, one per cell, with no gaps or overlaps. This is milestone one.
+{{< /checkpoint >}}
+
+## Milestone one
+
+You now drive the arm through a static pack from your own code: connect, read back the taught anchor poses, and run a pick-and-place cycle for each bottom-layer cell, with no obstacle avoidance yet. That is a complete, working result for this workshop. Phase 5 adds the second layer and teaches the motion service about the cubes already on the pallet, so it plans around them instead of through them.
+
+{{< workshop-nav >}}
diff --git a/docs/tutorials/so-arm101-palletizing/05-avoid-placed-cubes.md b/docs/tutorials/so-arm101-palletizing/05-avoid-placed-cubes.md
new file mode 100644
index 0000000000..8edee4b976
--- /dev/null
+++ b/docs/tutorials/so-arm101-palletizing/05-avoid-placed-cubes.md
@@ -0,0 +1,208 @@
+---
+title: "Phase 5: Avoid placed cubes"
+linkTitle: "5. Avoid placed cubes"
+type: "docs"
+slug: "avoid-placed-cubes"
+weight: 50
+description: "Model placed cubes and the held cube in WorldState so the planner stacks the full two layers collision-free."
+workshop: "so-arm101-palletizing"
+toc_hide: true
+phase: 5
+phase_total: 6
+prev: "/tutorials/so-arm101-palletizing/pack-from-python/"
+next: "/tutorials/so-arm101-palletizing/inline-module/"
+languages: ["python"]
+---
+
+In this phase you teach the motion service about the cubes already on the pallet and the cube in the gripper, so it plans around them instead of through them. Finishing this phase is milestone two: a full, collision-free two-layer pack.
+
+## What the planner already knows
+
+The motion service already knows about every component you configured with a frame. The arm and the gripper both went into the frame system in Phase 2, so the planner accounts for their shapes on every move without you doing anything. What it does not know about is anything you did not configure, like a cube sitting on the pallet. You add those per move, through the `world_state` argument to `motion.move`: the planner routes around exactly what that WorldState describes, and nothing more.
+
+In Phase 4, `move_gripper` always passed `world_state=None`, and that was fine. The bottom layer is flat, so nothing the arm carried or passed over could collide with anything else on the pallet. The second layer changes that. To stack a cube in a top-layer cell, the arm and the cube in its gripper travel over cubes already sitting in the bottom layer. With `world_state=None`, the planner has no idea those cubes exist and plans a path straight through them.
+
+This is the WorldState idea from [Phase 1](/tutorials/so-arm101-palletizing/platform-mental-model/#three-robotics-concepts-to-learn): obstacles are the things to avoid, and transforms are the things that move with the arm. This phase builds both from `self.placed`, the list your `place` method already appends to. See [Obstacles and WorldState](/motion-planning/obstacles/) for the full reference.
+
+## Keeping track of obstacles
+
+The planner reasons about collisions using **geometries**: simple shapes, a box, a sphere, or a capsule, that stand in for the volume a real object takes up. To keep the arm clear of a cube on the pallet, you describe that cube as a box geometry so the planner knows the space is occupied.
+
+Every pose in `self.placed` is a cube already on the pallet. Turn each one into a box geometry, a `RectangularPrism`, that the planner should avoid on the current move. Replace the `viam.proto.common` import from Phase 4 with this expanded block:
+
+```python
+from viam.proto.common import (
+ Pose,
+ PoseInFrame,
+ WorldState,
+ GeometriesInFrame,
+ Geometry,
+ RectangularPrism,
+ Vector3,
+)
+```
+
+Then add an `obstacles` method to the `Palletizer` class:
+
+```python
+ def obstacles(self):
+ """Build the WorldState of placed cubes for the planner to avoid."""
+ placed = [
+ GeometriesInFrame(
+ reference_frame="world",
+ geometries=[
+ Geometry(
+ center=Pose(x=p.x, y=p.y, z=p.z, o_x=0, o_y=0, o_z=1, theta=0),
+ box=RectangularPrism(dims_mm=Vector3(x=CUBE, y=CUBE, z=CUBE)),
+ label=f"placed-{i}",
+ )
+ ],
+ )
+ for i, p in enumerate(self.placed)
+ ]
+ return WorldState(obstacles=placed) if placed else None
+```
+
+Each placed cube becomes a box, `CUBE` millimeters on every side, in the `world` frame. A `Geometry`'s `center` is the middle of the box, and `self.placed` stores the tool pose you released each cube at, so each box is centered on that same x, y, z.
+
+These obstacles exist only for the duration of one move. They are not something you add to the machine's static configuration, because they change every cycle as `self.placed` grows. An obstacle in your machine configuration is fixed: you would configure something like the table the arm sits on that way, once, because it never moves. The cubes are different, so `obstacles` is a method that reads `self.placed` live and builds a fresh `world_state` on every call.
+
+Update `move_gripper` to accept and forward a `world_state`, replacing the hardcoded `None` from Phase 4:
+
+```python
+ async def move_gripper(self, pose: Pose, world_state=None):
+ destination = PoseInFrame(reference_frame="world", pose=pose)
+ await self.motion.move(
+ component_name=helpers.ARM,
+ destination=destination,
+ world_state=world_state,
+ )
+```
+
+`move_gripper` still defaults to no obstacles, so any call that does not pass a `world_state` behaves exactly as it did before.
+
+{{< alert title="Approximate cube centers" color="note" >}}
+Each placed-cube box is centered on the pose you released the cube at, which is the arm's end point, not the exact cube center. The two are close, within about half a cube height. If the arm clips the top edge of a placed cube, this offset is the first number to tune.
+{{< /alert >}}
+
+## Model the held cube
+
+The `obstacles` method covers the cubes on the pallet, but not the cube in the gripper. Between a `pick` and the matching `place`, the carried cube is not on the pallet, it moves wherever the arm moves. Modeling it as a fixed `world` obstacle would be wrong: obstacles are resolved to world coordinates once, at the start of planning, and then stay put.
+
+The carried cube belongs in `WorldState.transforms` instead. A `Transform` adds a new frame to the planner's world for the duration of a move, and because you parent that frame to the gripper, it moves with the arm. Add `Transform` to the import block, then extend `obstacles` to take a `held` flag:
+
+```python
+from viam.proto.common import (
+ Pose,
+ PoseInFrame,
+ WorldState,
+ GeometriesInFrame,
+ Geometry,
+ RectangularPrism,
+ Vector3,
+ Transform,
+)
+```
+
+```python
+ def obstacles(self, held=False):
+ """Build the WorldState for this move: placed cubes as obstacles, and
+ the carried cube as a transform that rides the gripper."""
+ placed = [
+ GeometriesInFrame(
+ reference_frame="world",
+ geometries=[
+ Geometry(
+ center=Pose(x=p.x, y=p.y, z=p.z, o_x=0, o_y=0, o_z=1, theta=0),
+ box=RectangularPrism(dims_mm=Vector3(x=CUBE, y=CUBE, z=CUBE)),
+ label=f"placed-{i}",
+ )
+ ],
+ )
+ for i, p in enumerate(self.placed)
+ ]
+ transforms = []
+ if held:
+ transforms.append(
+ Transform(
+ reference_frame="held-cube",
+ pose_in_observer_frame=PoseInFrame(
+ reference_frame=helpers.GRIPPER,
+ pose=Pose(x=0, y=0, z=CUBE / 2, o_x=0, o_y=0, o_z=1, theta=0),
+ ),
+ physical_object=Geometry(
+ center=Pose(x=0, y=0, z=0),
+ box=RectangularPrism(dims_mm=Vector3(x=CUBE, y=CUBE, z=CUBE)),
+ label="held-cube",
+ ),
+ )
+ )
+ if not placed and not transforms:
+ return None
+ return WorldState(obstacles=placed, transforms=transforms)
+```
+
+In the `Transform`, `pose_in_observer_frame` names the parent frame (`helpers.GRIPPER`) and the pose of the new `held-cube` frame relative to it, and `physical_object` gives that frame a cube-shaped geometry for collision checking. Because the parent is the gripper, the new frame rides the arm, so the planner tracks the carried cube through the whole motion instead of freezing it in place. This is the transform half of WorldState from Phase 1, the same runtime attach pattern described in [Attach and detach geometries](/motion-planning/obstacles/attach-detach-geometries/). Placed cubes stay in `obstacles`; only the carried cube goes in `transforms`.
+
+The offset `(0, 0, CUBE / 2)` places the cube just past the gripper's fingertips. Like the placed-cube center, it is a reasonable starting guess you may need to nudge.
+
+{{< alert title="If the first carry move fails on a collision" color="note" >}}
+Because the held-cube geometry sits right at the gripper (`z=CUBE / 2`), the first move that passes `held=True` can fail with a "start state in collision" error: the cube geometry overlaps the gripper's own fingers at the start pose. That pair of shapes is allowed to touch, so you tell the planner to ignore it with a collision specification that names the gripper and the `held-cube` frame. See [Attach and detach geometries](/motion-planning/obstacles/attach-detach-geometries/) for that pattern; this draft leaves it out to keep the code short.
+{{< /alert >}}
+
+## Run the full two-layer pack
+
+With `obstacles` in place, update `pick`, `place`, and `pack` to pass it, and extend the loop from four cubes to all eight:
+
+```python
+ async def pick(self):
+ """Pick the cube waiting on the staging spot and lift it clear."""
+ staging = helpers.STAGING_POSE
+ hover = down_pose(staging.x, staging.y, staging.z + APPROACH)
+ grasp = down_pose(staging.x, staging.y, staging.z - GRASP_DEPTH)
+ await self.move_gripper(hover, self.obstacles())
+ await self.move_gripper(grasp, self.obstacles())
+ await self.gripper.grab()
+ await self.move_gripper(hover, self.obstacles(held=True))
+
+ async def place(self, seq: int):
+ """Place the held cube into grid cell `seq`."""
+ target = helpers.grid(helpers.PALLET_ORIGIN, PITCH, CUBE)[seq]
+ hover = down_pose(target.x, target.y, target.z + APPROACH)
+ await self.move_gripper(hover, self.obstacles(held=True))
+ await self.move_gripper(down_pose(target.x, target.y, target.z), self.obstacles())
+ await self.gripper.open()
+ await self.move_gripper(hover, self.obstacles())
+ self.placed.append(target)
+
+ async def pack(self):
+ """Pack both layers: eight cubes, cells 0 through 7."""
+ for seq in range(8):
+ input(f"Place a cube on the staging spot, then press Enter (cell {seq})... ")
+ await self.pick()
+ await self.place(seq)
+ print(f"packed {len(self.placed)} cubes")
+```
+
+Look at where `held=True` shows up and where it does not. The last hover in `pick` and the first hover in `place` both carry the cube across open space toward or away from the pallet, so both pass `self.obstacles(held=True)`: the planner needs to know about the cube riding in the gripper while it is in transit. The final descent into an empty cell in `place`, and the lift straight back up afterward, drop the `held` flag and use `self.obstacles()` alone. By that point the cube is either about to be released or already released, so modeling it as still held would have the planner route around a cube that is about to overlap the cell it is being placed into. `self.placed.append(target)` still runs after the release, so the next cycle's `obstacles()` call sees this cube too.
+
+Run the full pack:
+
+```sh
+uv run palletizer.py pack
+```
+
+Hand-feed a cube to the staging spot for each of the eight prompts, the same rhythm as Phase 4. Keep the **3D scene** tab open while it runs; each time `place` appends to `self.placed`, the next move's obstacles include one more cube, and you can watch the set of avoided geometries grow cell by cell as the pallet fills.
+
+
+
+
+{{< checkpoint >}}
+After eight cycles, `pack` prints `packed 8 cubes` and both layers of the pallet are full, four cubes on the bottom and four stacked directly above them, with no collisions along the way. If the arm clips a placed cube, first confirm every call to `move_gripper` in `pick` and `place` passes a `world_state`, none should fall back to the `None` default; then confirm `self.placed.append(target)` runs after each successful `place`, so later cycles actually see the cubes placed before them.
+{{< /checkpoint >}}
+
+## Milestone two
+
+You now drive a full, collision-free two-layer pack: eight cubes, planned around each other automatically because you model placed cubes as obstacles and the held cube as a transform on every move. That is the complete robotics result this workshop set out to teach. Phase 6 is optional: it takes this same pack loop off your laptop and runs it as a module on the machine itself.
+
+{{< workshop-nav >}}
diff --git a/docs/tutorials/so-arm101-palletizing/06-inline-module.md b/docs/tutorials/so-arm101-palletizing/06-inline-module.md
new file mode 100644
index 0000000000..d0e846369e
--- /dev/null
+++ b/docs/tutorials/so-arm101-palletizing/06-inline-module.md
@@ -0,0 +1,147 @@
+---
+title: "Phase 6: Wrap the loop in a module"
+linkTitle: "6. Inline module"
+type: "docs"
+slug: "inline-module"
+weight: 60
+description: "Wrap the working pack loop in an inline module so it runs on the machine and can be triggered through DoCommand."
+workshop: "so-arm101-palletizing"
+toc_hide: true
+phase: 6
+phase_total: 6
+prev: "/tutorials/so-arm101-palletizing/avoid-placed-cubes/"
+next: "/tutorials/so-arm101-palletizing/wrap-up/"
+languages: ["python"]
+---
+
+This phase is optional. It takes the working pack loop from Phase 5 off your laptop and onto the machine, where it runs as a module and can be triggered on demand instead of from a script.
+
+## Why use a module
+
+A [module](/build-modules/) packages a resource so `viam-server` runs it directly, the same way the `so101` module you configured in Phase 2 runs the arm and gripper. Packaging your pack loop as a module puts your control code on that same footing.
+
+Reach for a module when one of these is true for your setup:
+
+- The pack has to keep running after you close your laptop.
+- The pack has to restart on its own after a crash or a machine reboot.
+- You want to deploy updates without pushing code from a laptop by hand.
+- You want the pack to run on a schedule instead of a manual trigger.
+
+If none of those apply, stop here. Phase 5 already gave you the complete robotics result 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 control code, so it is what you use here.
+
+## What changes when you package it
+
+The pick, place, and pack logic from Phase 5 is unchanged, moved into a module's lifecycle methods with no change to what it does. Only the wiring around that logic changes, in two places:
+
+- **How the module gets its resources.** Your script called `Gripper.from_robot`, `MotionClient.from_robot`, and read `helpers.ARM` after it connected. A module does not connect to itself, so it cannot call `from_robot` the same way; instead it receives its resources through **dependency injection**.
+- **How the logic gets triggered.** Your script ran top to bottom under `__main__`, driven by the `STEPS` dictionary and a command-line verb. A module runs its logic behind a **`do_command`** entry point that something else calls, such as the CONTROL tab's test card.
+
+The next three sections cover creating the module, then those two changes in turn.
+
+## Create a control code module
+
+On the **CONFIGURE** tab, click the **+** icon and select **Code**. Choose to create a Viam-hosted module with an inline editor, proceed past the information about configuring components, and select Python as the language. The Viam app creates a new configured resource with an embedded code editor in your browser, seeded with a generated module skeleton.
+
+
+
+Control code like this is typically modeled as a **generic service**: a resource with no specialized API of its own, so you can put arbitrary logic behind it. The skeleton builds that service on the `EasyResource` mixin, a convenience base class 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.
+
+Over the next two sections you move the pack loop into this skeleton: the typed resource handles you built with `from_robot` in Phase 4 become dependency injection through `new`, and `pick`, `place`, and `pack` gather behind the `do_command` entry point.
+
+## Dependency injection
+
+A script builds its resource handles once, right after it connects: `Gripper.from_robot(robot, helpers.GRIPPER)`, `MotionClient.from_robot(robot, helpers.MOTION)`, and `helpers.ARM` for the arm's name. A module works differently: it relies on `viam-server` to provide its dependencies to its constructor, which it declares through a configuration validation step. Two lifecycle methods carry this:
+
+- `validate_config` runs before your module starts and declares which resources it depends on, so `viam-server` knows to hold your module back until those resources are online. 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 required dependencies.
+- `new` receives the resolved dependencies as a mapping keyed by resource name, and this is where you build the typed handles `pick`, `place`, and `pack` call. It reads the same config attributes to look each dependency up by its configured name.
+
+The two anchor poses you taught by hand in Phase 3, staging and pallet origin, come in the same way: instead of reading `helpers.STAGING_POSE` and `helpers.PALLET_ORIGIN` from a Python file, the module reads them as config attributes.
+
+A short illustrative sketch of both methods:
+
+```python
+from viam.components.arm import Arm
+from viam.components.gripper import Gripper
+from viam.services.motion import Motion
+from viam.utils import struct_to_dict
+
+
+@classmethod
+def validate_config(cls, config):
+ attrs = struct_to_dict(config.attributes)
+ required_deps = []
+ for key in ("arm", "gripper"):
+ if key not in attrs or not attrs[key]:
+ raise ValueError(f"attribute '{key}' (non-empty string) is required")
+ required_deps.append(attrs[key])
+ 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"])]
+ self.gripper = dependencies[Gripper.get_resource_name(attrs["gripper"])]
+ self.motion = dependencies[Motion.get_resource_name("builtin")]
+ self.staging_pose = attrs["staging_pose"]
+ self.pallet_origin = attrs["pallet_origin"]
+ self.placed = []
+ return self
+```
+
+This sketch is illustrative, not a complete file: fill in the pose attributes with however you choose to encode `x`, `y`, and `z` in the config (a small object with three numeric fields works well), and carry over `PITCH`, `CUBE`, `APPROACH`, and `GRASP_DEPTH` as module-level constants exactly as they were in `palletizer.py`.
+
+## Trigger the module with do_command
+
+With dependencies wired up, assemble the pack loop into a `run_pack` method on the module, using `self.arm`, `self.gripper`, and `self.motion` in place of the resource handles a script built with `from_robot`. The pick, place, and obstacle logic from Phase 5 carries over unchanged; only the resource handles and the pose source are different.
+
+The Phase 5 `pack` loop paused on `input()` before each cycle so you could hand-feed a cube to the staging spot. A module runs on the machine with no attached terminal, so it cannot call `input()`. Drop the pause and let `run_pack` loop straight through all eight cells; unattended operation assumes something supplies cubes to the staging spot on its own, for example a feeder, which is out of scope for this workshop.
+
+```python
+ async def run_pack(self):
+ """Pack both layers: eight cubes, cells 0 through 7. No hand-feed pause."""
+ for seq in range(8):
+ await self.pick()
+ await self.place(seq)
+ return len(self.placed)
+```
+
+One robustness note worth calling out: `self.gripper.grab()` returns a boolean, and on grippers that sense their grip it reports whether the jaws actually closed on something. The SO-101's finger gripper does not sense grip, so its `grab()` returns `True` on command completion whether or not a cube is there. If your gripper does report holding state, checking that return value before lifting lets a module catch a missed grasp instead of carrying an empty gripper through the place step; treat that hold-check as an enhancement to add when your hardware supports it, rather than something the SO-101 provides on its own.
+
+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:
+
+```python
+async def do_command(self, command, *, timeout=None, **kwargs):
+ if command.get("action") == "pack":
+ count = await self.run_pack()
+ return {"packed": count}
+ return {}
+```
+
+{{< alert title="Module build feedback loop" color="note" >}}
+Saving an inline Python module triggers a cloud build that takes about a minute. Give it that minute rather than assuming the save failed.
+{{< /alert >}}
+
+## Run the pack sequence
+
+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 the `so101` module downloading back in Phase 2.
+
+
+
+{{< checkpoint >}}
+The module finishes its cloud build and starts without errors in the **LOGS** tab, and its resource shows online on the **CONFIGURE** tab. If the build fails, read the build log for the specific error; a missing import or a syntax error carried over from `palletizer.py` is the most common cause.
+{{< /checkpoint >}}
+
+From the **CONTROL** tab, find your module's test card and send `{"action": "pack"}` to run a full eight-cube pack on the machine, no laptop script required. To compare your module against a finished one, read the complete [`palletizer.py` reference](https://github.com/viam-devrel/mini-palletizer/blob/main/reference/palletizer.py) in the companion repo.
+
+
+
+{{< checkpoint >}}
+Sending `{"action": "pack"}` from the test card runs the eight-cell pack loop on the machine, the same collision-free motion you drove from your laptop in Phase 5, now with no laptop in the loop and no hand-feed pause. If the command errors out partway through, check the LOGS tab for the raised exception, most likely a pose attribute that does not match what you taught in Phase 3, or the start-state collision from the held-cube geometry if you carried that over without a collision specification.
+{{< /checkpoint >}}
+
+{{< workshop-nav >}}
diff --git a/docs/tutorials/so-arm101-palletizing/07-wrap-up.md b/docs/tutorials/so-arm101-palletizing/07-wrap-up.md
new file mode 100644
index 0000000000..f0ebbaf457
--- /dev/null
+++ b/docs/tutorials/so-arm101-palletizing/07-wrap-up.md
@@ -0,0 +1,47 @@
+---
+title: "Wrap-up and next steps"
+linkTitle: "Wrap-up"
+type: "docs"
+slug: "wrap-up"
+weight: 70
+description: "Review what you built in the SO-ARM101 palletizing workshop, the parts of the Viam platform you exercised, and where to take your solution next."
+workshop: "so-arm101-palletizing"
+toc_hide: true
+prev: "/tutorials/so-arm101-palletizing/inline-module/"
+languages: ["python"]
+---
+
+Congratulations, you have finished the SO-ARM101 palletizing workshop! Starting from an empty machine, you configured a real desktop arm, taught it the cell by hand, and wrote the code that packs cubes onto a pallet.
+
+
+
+## What you built
+
+- **Milestone one.** You drove the arm through a static bottom-layer pack from your own Python script, proving your connection, your configured resources, and the poses you taught by hand all hold up under real code.
+- **Milestone two.** You closed the loop with obstacle avoidance: the motion service plans a collision-free path around the cubes already on the pallet and the cube in the gripper, so the second layer stacks cleanly on top of the first.
+
+If you completed the optional Phase 6, you also packaged that same pack loop as a module that runs on the machine itself.
+
+## What you exercised on the platform
+
+This workshop was small on purpose, but it touched most of the moving parts you will use on any Viam machine:
+
+- **Configuration and runtime:** the Viam app as the single source of truth (the CONFIGURE tab and its JSON view), `viam-server` running your resources, and the module system, including a discovery service that suggested the arm and gripper configuration for you.
+- **Resources:** the arm and gripper components from the SO-ARM101 module, with the gripper attached to the arm through the frame system.
+- **Frame system and motion:** placing the arm at the world origin so hand-taught poses are world poses, teaching real-world anchor poses by back-driving the arm with torque disabled, and letting the motion service plan to the arm's end point. You also saw how a WorldState of placed cubes and the held cube keeps the planner from routing through the stack.
+- **Code:** the Python SDK (`RobotClient`, the typed `Gripper` and `MotionClient`, and `motion.move`), built up one method at a time into `palletizer.py`, plus packaging that same script as an inline module.
+
+## Where to go next
+
+Everything above is a foundation you can build on. A few directions, each with a starting point in the docs:
+
+- **Extend the pack.** Add more layers, change the grid pattern, or force a straight-line descent into each cell with a [motion constraint](/motion-planning/move-an-arm/move-with-constraints/).
+- **Add perception.** Replace the fixed staging spot with a camera that finds cubes: [capture and sync images](/data/capture-sync/), [build a dataset and train a model](/train/train-a-model/), then deploy it through the [ML model vision service](/reference/services/vision/mlmodel/) so the arm picks whatever it sees.
+- **Build an interface.** Put a browser UI in front of the cell with a [Viam application](/build-apps/): start a pack and watch progress from a dashboard instead of a terminal.
+- **Operationalize it.** Reuse this configuration across machines with a [fragment](/hardware/fragments/) and [capture data](/data/capture-sync/) from every run.
+- **Run the module unattended.** The optional Phase 6 module runs a pack on demand through `do_command`. Drive it on a cadence with a [trigger](/reference/triggers/) so the cell packs on its own.
+- **Explore the rest of the platform.** The same patterns work from other [SDKs](/reference/sdks/) (Go, TypeScript, C++, Flutter) and across the full [component and service APIs](/reference/apis/).
+
+When you are ready to build on your own hardware, the [Viam documentation](/) and the [module registry](https://app.viam.com/registry) are where to start.
+
+{{< workshop-nav >}}
diff --git a/docs/tutorials/so-arm101-palletizing/_index.md b/docs/tutorials/so-arm101-palletizing/_index.md
new file mode 100644
index 0000000000..6548430dfd
--- /dev/null
+++ b/docs/tutorials/so-arm101-palletizing/_index.md
@@ -0,0 +1,75 @@
+---
+title: "Miniature Palletizing with the SO-ARM101"
+linkTitle: "SO-ARM101 Palletizing Workshop"
+type: "docs"
+weight: 60
+description: "Build a desktop palletizing robot with an affordable SO-ARM101 arm. Teach its poses by hand, then program a collision-free pick-and-stack in Python."
+authors: []
+level: "Intermediate"
+languages: ["python"]
+viamresources: ["arm", "gripper", "motion", "frame_system"]
+platformarea: ["mobility", "core"]
+tags: ["tutorial", "workshop", "arm", "motion", "frame-system"]
+workshop: "so-arm101-palletizing"
+workshop_overview: true
+time_estimate: "2 hours"
+hardware:
+ - "SO-ARM101 (5-DOF + gripper)"
+ - "Eight ~20 mm cubes"
+ - "Personal computer"
+companion_repo: "https://github.com/viam-devrel/mini-palletizer"
+no_list: true
+---
+
+Warehouse robots spend all day stacking boxes onto pallets. In this workshop you build the same thing in miniature, on your desk: an affordable SO-ARM101 arm that picks up cubes and stacks them neatly, two layers at a time.
+
+It is a hands-on introduction to robot manipulation with Viam, for developers and makers who would rather program a real arm than a simulator. You do not need industrial hardware or prior robotics experience, just a desktop arm you can build yourself. You configure it, teach it where the cubes and the pallet are by guiding it with your own hands, then write the Python that runs the whole packing routine. By the end, the arm packs a full two-by-two-by-two stack of cubes on its own, driven entirely by code you wrote.
+
+
+
+## Required hardware
+
+- **An SO-ARM101 arm** with its finger gripper, connected to your computer over USB.
+- **Eight cubes and a pallet mat.** You need eight cubes about 20 mm on a side, a two-by-two pallet grid to stack them on, and a staging spot to feed cubes from. The companion project includes a printable template: paper cube nets you fold into 20 mm cubes, and a mat that marks the pallet grid and the staging spot at the exact spacing the code expects. Print it, cut it out, and you are ready. Wooden or foam 20 mm cubes work too if you have them.
+- **A personal computer** running `viam-server`, with the arm plugged into it over USB.
+
+## Phases
+
+1. **[Platform mental model](/tutorials/so-arm101-palletizing/platform-mental-model/)**
+2. **[Configure the SO-ARM101](/tutorials/so-arm101-palletizing/configure-the-arm/)**
+3. **[Teach the cell by hand](/tutorials/so-arm101-palletizing/teach-the-cell/)**
+4. **[Pack from Python](/tutorials/so-arm101-palletizing/pack-from-python/)** (milestone one: a static pack from your own code)
+5. **[Avoid placed cubes](/tutorials/so-arm101-palletizing/avoid-placed-cubes/)** (milestone two: a collision-free full pack)
+6. **[Wrap it in a module](/tutorials/so-arm101-palletizing/inline-module/)** (optional)
+
+When you finish, the **[wrap-up](/tutorials/so-arm101-palletizing/wrap-up/)** reviews what you built and points to next steps.
+
+## Companion code
+
+All supporting files for this workshop live in the [viam-devrel/mini-palletizer](https://github.com/viam-devrel/mini-palletizer) repository, including the printable cube-and-pallet template. `helpers.py` is provided for you; you build `palletizer.py` yourself as you work through the phases.
+
+## Prerequisites
+
+This is a self-serve workshop, so confirm each of the following before you start:
+
+- **A Viam account with an online machine.** Log in at [app.viam.com](https://app.viam.com), [create a machine](https://docs.viam.com/set-up-a-machine/first-machine/), and confirm the green **Live** indicator before you begin.
+- **Python 3.10 or newer.** Install it with [uv](https://docs.astral.sh/uv/getting-started/installation/) (recommended) or from [python.org](https://www.python.org/downloads/).
+- **An SO-ARM101 that's built, has its motors configured, and is calibrated.** Follow the first-time arm setup steps in the [SO-ARM101 module](https://app.viam.com/module/devrel/so101-arm) documentation: configure the motors, build the arm, and calibrate it.
+
+### Validate your environment
+
+Before starting Phase 1, confirm your environment is ready:
+
+```sh
+python3 --version # 3.10 or newer
+uv run --with viam-sdk python -c "import viam; print(viam.__version__)" # prints a version
+```
+
+If either command fails, revisit the checklist above before continuing.
+
+### Where to start
+
+
+
+- **Arm built and machine online (`viam-server` running):** start at [Phase 1](/tutorials/so-arm101-palletizing/platform-mental-model/).
+- **Still building your SO-ARM101:** complete the [first-time arm setup](https://app.viam.com/module/devrel/so101-arm) (configure the motors, build the arm, and calibrate it), then return here for [Phase 1](/tutorials/so-arm101-palletizing/platform-mental-model/).
diff --git a/docs/tutorials/so-arm101-palletizing/_phase-template.md b/docs/tutorials/so-arm101-palletizing/_phase-template.md
new file mode 100644
index 0000000000..afbf0192fa
--- /dev/null
+++ b/docs/tutorials/so-arm101-palletizing/_phase-template.md
@@ -0,0 +1,28 @@
+---
+title: "Phase N: Title in under 70 characters"
+linkTitle: "N. Short title"
+type: "docs"
+slug: "phase-slug"
+weight: 0 # 10, 20, 30, ... in 10s for sidebar order
+description: "One-sentence description of this phase."
+workshop: "so-arm101-palletizing"
+toc_hide: true
+phase: 0
+phase_total: 6
+time_estimate: "NN minutes"
+prev: "/tutorials/so-arm101-palletizing/previous-slug/"
+next: "/tutorials/so-arm101-palletizing/next-slug/"
+languages: ["python"]
+draft: true # set to false when this phase is ready to publish
+headless: true # REMOVE this line when you copy the template to a real phase page
+---
+
+
+
+## Section heading
+
+{{< checkpoint >}}
+What the learner runs, and the expected result that means "continue".
+{{< /checkpoint >}}
+
+{{< workshop-nav >}}