<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" xmlns:googleplay="http://www.google.com/schemas/play-podcasts/1.0"><channel><title><![CDATA[Protomota Lab]]></title><description><![CDATA[Solo founder. Team of AI agents. I build products and run everything with orchestrated agent stacks — no human team behind it. This is where I share what actually works.]]></description><link>https://newsletter.protomota.com</link><image><url>https://substackcdn.com/image/fetch/$s_!gWIA!,w_256,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5dfb5009-bc5b-49a9-b0ce-83b241af7782_1000x1000.png</url><title>Protomota Lab</title><link>https://newsletter.protomota.com</link></image><generator>Substack</generator><lastBuildDate>Sun, 14 Jun 2026 14:54:41 GMT</lastBuildDate><atom:link href="https://newsletter.protomota.com/feed" rel="self" type="application/rss+xml"/><copyright><![CDATA[Protomota]]></copyright><language><![CDATA[en]]></language><webMaster><![CDATA[braddunlap@substack.com]]></webMaster><itunes:owner><itunes:email><![CDATA[braddunlap@substack.com]]></itunes:email><itunes:name><![CDATA[Brad Dunlap]]></itunes:name></itunes:owner><itunes:author><![CDATA[Brad Dunlap]]></itunes:author><googleplay:owner><![CDATA[braddunlap@substack.com]]></googleplay:owner><googleplay:email><![CDATA[braddunlap@substack.com]]></googleplay:email><googleplay:author><![CDATA[Brad Dunlap]]></googleplay:author><itunes:block><![CDATA[Yes]]></itunes:block><item><title><![CDATA[Project Ogre Part 5: Autonomous Navigation with Nav2]]></title><description><![CDATA[Bringing it all together - autonomous waypoint navigation using ROS2 Nav2 with SLAM maps and RL-trained velocity control.]]></description><link>https://newsletter.protomota.com/p/project-ogre-part-5-autonomous-navigation</link><guid isPermaLink="false">https://newsletter.protomota.com/p/project-ogre-part-5-autonomous-navigation</guid><dc:creator><![CDATA[Brad Dunlap]]></dc:creator><pubDate>Sun, 31 May 2026 19:28:44 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/8ae8146c-c5be-42d7-81a2-db810fdfe95d_994x1000.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>Autonomous Navigation with Nav2</h2><p>This is the final part of the Project Ogre series. We've built the <a href="https://protomota.com/blog/project-ogre-part1-building-robot">robot</a>, created a <a href="https://protomota.com/blog/project-ogre-part2-isaac-sim">digital twin</a>, built <a href="https://protomota.com/blog/project-ogre-part3-slam-mapping">maps with SLAM</a>, and trained an <a href="https://protomota.com/blog/project-ogre-part4-isaac-lab-training">RL policy</a>. Now we bring it all together with Nav2 for autonomous waypoint navigation.</p><blockquote><p><strong>Project Goal:</strong> Navigate autonomously to user-specified waypoints while avoiding obstacles, using the saved map and trained policy.</p></blockquote><blockquote><p><strong>&#128230; Full Source Code:</strong> Nav2 configuration and launch files are available at <a href="https://github.com/protomota/ogre-slam">github.com/protomota/ogre-slam</a></p></blockquote><p>The video below shows Nav2 autonomous navigation in action. On the left is RViz displaying the map, costmaps, and planned path. On the right is Isaac Sim where the robot navigates autonomously through the maze using the trained RL policy to execute velocity commands.</p><p>&#9654; <a href="https://www.youtube.com/watch?v=IfBRDBTknWg">Watch the video on YouTube</a></p><h2>The Nav2 Stack</h2><p>Nav2 (Navigation 2) is ROS2's navigation framework. It handles:</p><ol><li><p><strong>Localization</strong>: Where is the robot on the map?</p></li><li><p><strong>Global Planning</strong>: What's the best path to the goal?</p></li><li><p><strong>Local Planning</strong>: How do we follow that path while avoiding new obstacles?</p></li><li><p><strong>Recovery</strong>: What to do when stuck?</p></li></ol><p>For Project Ogre, the architecture looks like:</p><pre><code>Saved Map &#8594; AMCL (localization)
                    &#8595;
User Goal &#8594; Global Planner &#8594; Local Planner &#8594; /cmd_vel
                                                  &#8595;
                                          Policy Controller
                                                  &#8595;
                                           Wheel Commands</code></pre><p>The RL policy sits between Nav2's velocity commands and the actual wheel control, translating ideal commands into optimal wheel velocities.</p><h2>Understanding Costmaps</h2><p>Nav2 uses <strong>costmaps</strong> to represent navigable space. Each cell in the grid has a cost (0-254) indicating traversal difficulty:</p><pre><code>Cost   Name            Meaning
-----  --------------  ----------------------------------------
0      FREE_SPACE      Safe to traverse
1-252  INFLATED        Near obstacles (increasingly expensive)
253    INSCRIBED       Inside robot's radius (collision likely)
254    LETHAL          Obstacle (certain collision)
255    NO_INFORMATION  Unknown space</code></pre><h3>Two Costmaps</h3><p><strong>Global Costmap</strong> (<code>/global_costmap/costmap</code>)</p><ul><li><p>Covers the entire map</p></li><li><p>Used for path planning</p></li><li><p>Updates from static map + sensors</p></li></ul><p><strong>Local Costmap</strong> (<code>/local_costmap/costmap</code>)</p><ul><li><p>Rolling 3m &#215; 3m window around robot</p></li><li><p>Used for reactive obstacle avoidance</p></li><li><p>Updates continuously from sensors</p></li></ul><h3>Inflation</h3><p>Obstacles are "inflated" by the robot's radius to prevent collision. The key parameters:</p><pre><code>robot_radius: 0.20        # Inscribed radius
inflation_radius: 0.35    # How far to spread costs
cost_scaling_factor: 3.0  # How quickly costs drop off</code></pre><p>If the robot clips walls, increase <code>inflation_radius</code>. If it can't navigate narrow passages, decrease it.</p><h2>Nav2 Configuration</h2><p>The full configuration lives in <code>config/nav2_params.yaml</code>. Here are the key sections:</p><h3>AMCL (Localization)</h3><p>AMCL (Adaptive Monte Carlo Localization) tracks the robot's position on the map using laser scan matching:</p><pre><code>amcl:
  ros__parameters:
    base_frame_id: "base_link"
    odom_frame_id: "odom"
    scan_topic: /scan
    min_particles: 500
    max_particles: 2000
    update_min_d: 0.1  # Update after 10cm movement
    update_min_a: 0.2  # Update after ~11&#176; rotation</code></pre><h3>Global Planner</h3><p>We use Smac 2D for global path planning - it handles tight spaces better than the default NavFn:</p><pre><code>planner_server:
  ros__parameters:
    planner_plugins: ["GridBased"]
    GridBased:
      plugin: "nav2_smac_planner/SmacPlanner2D"
      tolerance: 0.50  # Search 50cm around goal
      max_planning_time: 2.0</code></pre><p>The <code>tolerance</code> parameter is crucial - it defines how close the robot needs to get to the exact goal point. Higher tolerance helps when goals are near obstacles.</p><h3>Local Controller</h3><p>The local controller follows the global path while avoiding obstacles. For mecanum robots, we use a velocity-based approach:</p><pre><code>controller_server:
  ros__parameters:
    controller_plugins: ["FollowPath"]
    FollowPath:
      plugin: "dwb_core::DWBLocalPlanner"
      max_vel_x: 0.3
      max_vel_y: 0.3     # Strafing enabled for mecanum
      max_vel_theta: 1.0
      min_speed_xy: 0.0
      min_speed_theta: 0.0</code></pre><p>Note <code>max_vel_y</code> - this enables strafing, which is what makes mecanum navigation special.</p><h3>Behavior Server (Recovery)</h3><p>When the robot gets stuck, recovery behaviors kick in:</p><pre><code>behavior_server:
  ros__parameters:
    behavior_plugins: ["spin", "backup", "wait"]
    spin:
      plugin: "nav2_behaviors/Spin"
    backup:
      plugin: "nav2_behaviors/BackUp"</code></pre><p>The robot will try spinning, backing up, or waiting before giving up on a goal.</p><h2>Running Navigation</h2><h3>In Isaac Sim</h3><p><strong>Terminal 1: Start Isaac Sim</strong></p><ul><li><p>Load <code>usds/ogre.usd</code></p></li><li><p>Verify ROS2 Context has a matching Domain ID</p></li><li><p>Press Play</p></li></ul><p><strong>Terminal 2: Launch Navigation</strong></p><pre><code>cd ~/ros2_ws &amp;&amp; source install/setup.bash

ros2 launch ogre_slam navigation.launch.py \
  map:=~/ros2_ws/src/ogre-slam/maps/isaac_sim_map.yaml \
  use_sim_time:=true</code></pre><p>The <code>use_sim_time:=true</code> flag is critical - without it, TF transforms will time out because the simulation clock doesn't match wall clock.</p><p><strong>Terminal 3: Launch Policy Controller</strong></p><pre><code>conda deactivate
source ~/ros2_ws/install/setup.bash

ros2 launch ogre_policy_controller policy_controller.launch.py use_sim_time:=true</code></pre><p><strong>Terminal 4: Launch RViz (optional, for remote visualization)</strong></p><pre><code>./scripts/launch_isaac_sim_rviz.sh</code></pre><h3>On the Real Robot</h3><pre><code>cd ~/ros2_ws &amp;&amp; source install/setup.bash
ros2 launch ogre_slam navigation.launch.py \
  map:=~/ros2_ws/src/ogre-slam/maps/my_map.yaml</code></pre><p>This launches:</p><ul><li><p>AMCL localization with the saved map</p></li><li><p>RPLIDAR + RealSense D435 sensors</p></li><li><p>Full Nav2 stack</p></li><li><p>Web teleop for manual override</p></li></ul><h2>Navigating in RViz</h2><p>With navigation running, RViz becomes your command interface:</p><h3>Step 1: Set Initial Pose</h3><p>Click <strong>2D Pose Estimate</strong> in the toolbar, then click and drag on the map to indicate:</p><ul><li><p>Where the robot is</p></li><li><p>Which direction it's facing</p></li></ul><p>AMCL needs this initial hint to start tracking correctly. As the robot moves, localization will refine automatically.</p><h3>Step 2: Send Navigation Goals</h3><p>Click <strong>Nav2 Goal</strong> (or <strong>2D Goal Pose</strong>), then click on the map where you want the robot to go. Drag to set the final orientation.</p><p>The robot will:</p><ol><li><p>Plan a global path (green line in RViz)</p></li><li><p>Follow the path using local planning</p></li><li><p>Avoid obstacles detected in real-time</p></li><li><p>Execute recovery behaviors if stuck</p></li></ol><h3>Manual Override</h3><p>On the real robot, the web teleop interface at <code>http://10.21.21.45:8080</code> provides manual control. Any teleop commands override Nav2's autonomous control.</p><h2>The Complete Data Flow</h2><p>Let's trace a navigation command through the entire system:</p><pre><code>1. User clicks goal in RViz
         &#8595;
2. Global Planner computes path using costmap
         &#8595;
3. Local Controller generates /cmd_vel following path
         &#8595;
4. Policy Controller receives velocity command
         &#8595;
5. Neural network computes optimal wheel velocities
         &#8595;
6. /joint_command sent to robot/sim
         &#8595;
7. Robot moves, sensors update, localization refines
         &#8595;
   (loop continues until goal reached)</code></pre><p>The trained RL policy adds value at step 5 - it learns compensation for wheel slip, motor delays, and mechanical imperfections that pure kinematics can't handle.</p><h2>3D Obstacle Avoidance</h2><p>The RealSense D435 provides depth data that the RPLIDAR can't see - obstacles below the 2D scan plane like:</p><ul><li><p>Table legs</p></li><li><p>Stairs</p></li><li><p>Low furniture</p></li><li><p>Pets</p></li></ul><p>The depth camera publishes a PointCloud2 to <code>/camera_points</code>, which feeds into the local costmap's voxel layer:</p><pre><code>local_costmap:
  voxel_layer:
    plugin: "nav2_costmap_2d::VoxelLayer"
    observation_sources: scan camera
    camera:
      topic: /camera_points
      sensor_frame: camera_link
      max_obstacle_height: 0.5
      min_obstacle_height: 0.0</code></pre><p>Now the robot can detect and avoid 3D obstacles in real-time.</p><h2>Troubleshooting Navigation</h2><h3>"Legal potential but no path"</h3><p><strong>Cause</strong>: Goal is in an inflated zone near obstacles</p><p><strong>Solutions</strong>:</p><ol><li><p>Click goals further from walls</p></li><li><p>Increase <code>tolerance</code> in planner config</p></li><li><p>Switch to SmacPlanner2D (handles this better than NavFn)</p></li></ol><h3>Robot Clips Walls</h3><p><strong>Cause</strong>: Inflation radius too small</p><p><strong>Solution</strong>: Increase <code>inflation_radius</code> in both costmaps (try 0.40-0.50m)</p><h3>Robot Can't Enter Narrow Spaces</h3><p><strong>Cause</strong>: Inflation radius too large</p><p><strong>Solution</strong>: Decrease <code>inflation_radius</code> (try 0.30m)</p><h3>Localization Drifts</h3><p><strong>Cause</strong>: AMCL losing track, often from rapid motion or featureless areas</p><p><strong>Solutions</strong>:</p><ol><li><p>Move slower during navigation</p></li><li><p>Increase <code>max_particles</code></p></li><li><p>Ensure good initial pose estimate</p></li></ol><h3>TF_OLD_DATA Errors</h3><p><strong>Cause</strong>: Clock synchronization issues</p><p><strong>Solutions</strong>:</p><ul><li><p>In simulation: Ensure <code>use_sim_time:=true</code> on ALL nodes</p></li><li><p>On real robot: Check that odometry and SLAM are publishing current timestamps</p></li></ul><h3>Robot Spins Forever</h3><p><strong>Cause</strong>: Recovery behavior stuck in loop</p><p><strong>Solution</strong>: Cancel the goal and try a different destination. Check if there's an obstacle blocking all paths.</p><h2>Performance on Jetson</h2><p>Running the full Nav2 stack on Jetson Orin Nano requires some optimization:</p><h3>Memory Management</h3><pre><code>global_costmap:
  resolution: 0.10  # Coarser than SLAM (0.05) to save memory
local_costmap:
  resolution: 0.05  # Finer for local avoidance</code></pre><h3>CPU Optimization</h3><pre><code># Enable max performance mode
sudo /usr/bin/jetson_clocks

# Monitor resources
tegrastats</code></pre><h3>Disable Unnecessary Visualization</h3><p>Don't run RViz on the Jetson - use remote visualization instead. Save those CPU cycles for navigation.</p><h2>What We Built</h2><p>Over these five posts, we created a complete autonomous mobile robot:</p><ol><li><p><strong>Hardware</strong>: Mecanum drive robot with Jetson Orin Nano, RPLIDAR, and RealSense</p></li><li><p><strong>Odometry</strong>: Calibrated encoders with EKF sensor fusion</p></li><li><p><strong>Mapping</strong>: SLAM-generated occupancy grids</p></li><li><p><strong>Learning</strong>: RL-trained velocity controller</p></li><li><p><strong>Navigation</strong>: Full Nav2 stack with 3D obstacle avoidance</p></li></ol><p>The robot can now navigate autonomously to any point on the map, avoiding both static obstacles (walls from the map) and dynamic obstacles (detected in real-time by sensors).</p><h2>Future Improvements</h2><p>The system works, but there's always room to improve:</p><ul><li><p><strong>IMU Integration</strong>: Adding an IMU would dramatically improve odometry, especially during rotation</p></li><li><p><strong>Dynamic Obstacle Tracking</strong>: Currently obstacles are binary - tracking moving obstacles would enable smarter planning</p></li><li><p><strong>Sim-to-Real Transfer</strong>: Fine-tuning the RL policy on real-world data</p></li></ul><p>---</p><p><strong>Project Ogre Summary:</strong></p><pre><code>Component     Technology
------------  --------------------------------
Computing     Jetson Orin Nano
Drive         Mecanum wheels (omnidirectional)
2D Sensing    RPLIDAR A1
3D Sensing    RealSense D435
SLAM          slam_toolbox
Localization  AMCL
Planning      Nav2 (Smac2D + DWB)
Control       RL-trained policy
Simulation    NVIDIA Isaac Sim</code></pre><p><strong>Key Launch Commands:</strong></p><pre><code># Mapping (simulation)
ros2 launch ogre_slam mapping.launch.py use_sim_time:=true

# Mapping (real robot)
./scripts/launch_mapping_session.sh

# Navigation (simulation)
ros2 launch ogre_slam navigation.launch.py \
  map:=~/ros2_ws/src/ogre-slam/maps/isaac_sim_map.yaml \
  use_sim_time:=true

# Navigation (real robot)
ros2 launch ogre_slam navigation.launch.py \
  map:=~/ros2_ws/src/ogre-slam/maps/my_map.yaml

# Policy controller
ros2 launch ogre_policy_controller policy_controller.launch.py</code></pre><p>Thanks for following along with Project Ogre. If you build something similar or have questions, let me know in the comments.</p><h2>Credits</h2><ul><li><p><strong>slam_toolbox</strong>: Steve Macenski</p></li><li><p><strong>robot_localization</strong>: Tom Moore</p></li><li><p><strong>RPLIDAR ROS</strong>: SLAMTEC</p></li><li><p><strong>Isaac Lab</strong>: NVIDIA</p></li><li><p><strong>Nav2</strong>: Open Robotics &amp; Nav2 maintainers</p></li></ul>]]></content:encoded></item><item><title><![CDATA[Project Ogre Part 4: Training a Mecanum Controller in Isaac Lab]]></title><description><![CDATA[Using reinforcement learning in NVIDIA Isaac Lab to train a neural network policy for optimal mecanum wheel velocity control.]]></description><link>https://newsletter.protomota.com/p/project-ogre-part-4-training-a-mecanum</link><guid isPermaLink="false">https://newsletter.protomota.com/p/project-ogre-part-4-training-a-mecanum</guid><dc:creator><![CDATA[Brad Dunlap]]></dc:creator><pubDate>Sun, 31 May 2026 19:28:23 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/8f8d7df0-177e-4e24-ad67-4f385d75c0c1_1200x676.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>Training in Isaac Lab</h2><p>This is Part 4 of the Project Ogre series. In <a href="https://protomota.com/blog/project-ogre-part3-slam-mapping">Part 3</a>, we built maps using SLAM. Now we'll train a neural network to optimally control the mecanum wheels using reinforcement learning in NVIDIA Isaac Lab.</p><blockquote><p><strong>Project Goal:</strong> Train an RL policy that converts velocity commands (vx, vy, vtheta) into optimal wheel velocities, learning the robot's real dynamics.</p></blockquote><blockquote><p><strong>&#128230; Full Source Code:</strong> Training environment and scripts are available at <a href="https://github.com/protomota/ogre-lab">github.com/protomota/ogre-lab</a></p></blockquote><p>The video below shows Isaac Lab training 4096 parallel instances of the robot simultaneously. Each instance learns from its own experiences, accelerating the training process dramatically compared to single-environment training.</p><p>&#9654; <a href="https://www.youtube.com/watch?v=_a5k1wXDSU8">Watch the video on YouTube</a></p><h2>Why Use Reinforcement Learning?</h2><p>Mecanum wheel kinematics have a clean theoretical model: given a desired body velocity (vx, vy, angular), you can compute the required wheel velocities using inverse kinematics. So why train a neural network?</p><p><strong>The theory doesn't match reality.</strong></p><p>Real robots have:</p><ul><li><p>Wheel slip on different surfaces</p></li><li><p>Motor response delays and non-linearities</p></li><li><p>Weight distribution asymmetries</p></li><li><p>Mechanical imperfections in wheel alignment</p></li></ul><p>An RL policy learns to compensate for these factors by experiencing thousands of trials in simulation. It discovers that "to go straight forward at 0.3 m/s, the right wheels need slightly higher velocity to compensate for weight imbalance."</p><p>The trained policy sits between Nav2's velocity commands and the actual wheel controllers, translating ideal commands into commands that achieve the desired behavior on the real robot.</p><h2>Isaac Lab Architecture</h2><p>Isaac Lab is NVIDIA's GPU-accelerated robot learning framework built on Isaac Sim. Key features:</p><ul><li><p><strong>Parallel environments</strong>: Train on 1024+ robots simultaneously</p></li><li><p><strong>GPU physics</strong>: PhysX runs entirely on GPU for massive speedup</p></li><li><p><strong>RSL-RL integration</strong>: Works with the RSL-RL library for PPO training</p></li></ul><p>The training loop runs at ~30Hz control rate with 120Hz physics, training policies in minutes rather than hours.</p><h2>The Training Environment</h2><p>The environment is defined in <code>ogre_navigation_env.py</code>. Let's break down the key components:</p><h3>Observation Space (10 dimensions)</h3><p>The policy sees:</p><pre><code>Index  Description
-----  -----------------------------------------
0-2    Target velocity command (vx, vy, vtheta)
3-5    Current base velocity (vx, vy, vtheta)
6-9    Current wheel velocities (fl, fr, rl, rr)</code></pre><p>This gives the policy enough information to understand both what it should achieve and what the robot is currently doing.</p><h3>Action Space (4 dimensions)</h3><p>The policy outputs wheel velocity targets for all four wheels:</p><ul><li><p>Front-left (FL)</p></li><li><p>Front-right (FR)</p></li><li><p>Rear-left (RL)</p></li><li><p>Rear-right (RR)</p></li></ul><p>Raw policy outputs are in [-1, 1] and scaled by <code>action_scale</code> (8.0 rad/s in the working config) to get actual wheel velocities.</p><h3>Reward Function</h3><p>The policy learns through reward signals:</p><pre><code># Main velocity tracking reward
vel_error = target_vel - current_vel
rew_vel_tracking = 2.0 * exp(-vel_error_norm / 0.25)

# Uprightness reward (+1 upright, -1 flipped)
rew_uprightness = 1.0 * up_z

# Wheel symmetry penalty (prevents veering during straight motion)
rew_symmetry = -1.0 * (left_avg - right_avg)^2</code></pre><p>The velocity tracking reward uses exponential decay - perfect tracking gives maximum reward, with diminishing returns as error increases. The symmetry penalty discourages the policy from commanding asymmetric wheel velocities when the target is straight-line motion.</p><h3>Episode Structure</h3><ul><li><p><strong>Duration</strong>: 10 seconds per episode</p></li><li><p><strong>Termination</strong>: Episode ends early if robot flips (base height &lt; 0.02m)</p></li><li><p><strong>Reset</strong>: Random target velocities sampled each episode</p></li></ul><p>Random targets force the policy to generalize across the full velocity space rather than memorizing a single trajectory.</p><h2>Robot Configuration</h2><p>The USD robot model must match the real hardware:</p><pre><code>OGRE_MECANUM_CFG = ArticulationCfg(
    spawn=sim_utils.UsdFileCfg(
        usd_path="~/ros2_ws/src/ogre-slam/usds/ogre_robot.usd",
    ),
    init_state=ArticulationCfg.InitialStateCfg(
        pos=(0.0, 0.0, 0.04),  # Wheel axle height
    ),
    actuators={
        "wheels": ImplicitActuatorCfg(
            joint_names_expr=["fl_joint", "fr_joint", "rl_joint", "rr_joint"],
            effort_limit=10.0,
            stiffness=0.0,
            damping=10.0,
        ),
    },
)</code></pre><p>Key parameters:</p><ul><li><p><code>wheel_radius</code>: 0.040m (40mm)</p></li><li><p><code>wheelbase</code>: 0.095m (95mm)</p></li><li><p><code>track_width</code>: 0.205m (205mm)</p></li></ul><h2>The Wheel Sign Challenge</h2><p>One non-obvious issue: the USD model has the right wheels (FR, RR) with opposite joint axis orientation from the left wheels. Sending <code>[+10, +10, +10, +10]</code> to all wheels makes the robot spin instead of moving forward.</p><p>The solution is sign correction applied in both training and deployment:</p><p><strong>Training (_apply_action):</strong></p><pre><code>corrected_actions = self.actions.clone()
corrected_actions[:, 0] *= -1  # FR (right wheel)
corrected_actions[:, 1] *= -1  # RR (right wheel)
self.robot.set_joint_velocity_target(corrected_actions)</code></pre><p><strong>Deployment (Isaac Sim action graph):</strong> The same correction must be applied when running the trained policy - multiply FR and RR velocities by -1.</p><p>This creates a normalized action space where <code>[+,+,+,+]</code> always means "all wheels forward" regardless of joint axis conventions.</p><h2>Running Training</h2><h3>Prerequisites</h3><ul><li><p>Isaac Sim 5.0+</p></li><li><p>Isaac Lab installed at <code>~/isaac-lab/IsaacLab</code></p></li><li><p>Conda environment <code>env_isaaclab</code></p></li></ul><h3>Step 1: Sync the Environment</h3><p>The training environment must be copied to Isaac Lab's task directory:</p><pre><code>cd ~/ogre-lab
./scripts/sync_env.sh</code></pre><p>This copies <code>ogre_navigation/</code> to Isaac Lab's source tree where it can be discovered and imported.</p><h3>Step 2: Run Training</h3><pre><code>conda activate env_isaaclab
cd ~/ogre-lab
./scripts/train_ogre_navigation.sh</code></pre><p>Default settings:</p><ul><li><p>4096 parallel environments</p></li><li><p>1000 iterations</p></li><li><p>Headless mode (no visualization)</p></li></ul><p>For debugging, run with fewer environments and visualization:</p><pre><code>./scripts/train_ogre_navigation.sh 64 100</code></pre><h3>Step 3: Monitor with TensorBoard</h3><p>TensorBoard provides real-time visualization of training progress. In a separate terminal:</p><pre><code>conda activate env_isaaclab
tensorboard --logdir ~/isaac-lab/IsaacLab/logs/rsl_rl/ogre_navigation/</code></pre><p>Open http://localhost:6006 in your browser:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!s8jZ!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa1e5cc2b-27f1-4e79-b247-156d524ca479_1200x584.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!s8jZ!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa1e5cc2b-27f1-4e79-b247-156d524ca479_1200x584.png 424w, https://substackcdn.com/image/fetch/$s_!s8jZ!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa1e5cc2b-27f1-4e79-b247-156d524ca479_1200x584.png 848w, https://substackcdn.com/image/fetch/$s_!s8jZ!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa1e5cc2b-27f1-4e79-b247-156d524ca479_1200x584.png 1272w, https://substackcdn.com/image/fetch/$s_!s8jZ!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa1e5cc2b-27f1-4e79-b247-156d524ca479_1200x584.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!s8jZ!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa1e5cc2b-27f1-4e79-b247-156d524ca479_1200x584.png" width="728" height="354.29333333333335" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/a1e5cc2b-27f1-4e79-b247-156d524ca479_1200x584.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:false,&quot;imageSize&quot;:&quot;normal&quot;,&quot;height&quot;:584,&quot;width&quot;:1200,&quot;resizeWidth&quot;:728,&quot;bytes&quot;:165855,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!s8jZ!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa1e5cc2b-27f1-4e79-b247-156d524ca479_1200x584.png 424w, https://substackcdn.com/image/fetch/$s_!s8jZ!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa1e5cc2b-27f1-4e79-b247-156d524ca479_1200x584.png 848w, https://substackcdn.com/image/fetch/$s_!s8jZ!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa1e5cc2b-27f1-4e79-b247-156d524ca479_1200x584.png 1272w, https://substackcdn.com/image/fetch/$s_!s8jZ!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa1e5cc2b-27f1-4e79-b247-156d524ca479_1200x584.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p><strong>Key metrics to watch:</strong></p><ul><li><p><strong>Train/mean_reward</strong>: The average reward across all parallel environments. Should steadily increase as the policy learns. A healthy training run shows rewards climbing from near-zero to positive values.</p></li></ul><ul><li><p><strong>Train/mean_episode_length</strong>: How long episodes last before termination. Should approach the maximum (300 steps = 10 seconds at 30Hz). If episodes are short, robots are flipping or hitting termination conditions.</p></li></ul><ul><li><p><strong>Loss/value_function</strong>: The critic's prediction error. Should decrease over time as the value network learns to estimate expected returns. Spikes are normal early in training.</p></li></ul><ul><li><p><strong>Loss/surrogate</strong>: The PPO policy loss. Fluctuates during training but should generally trend downward.</p></li></ul><p>TensorBoard is invaluable for diagnosing training issues. If rewards plateau, you might need to adjust reward weights. If episode lengths stay low, the action scale might be too aggressive.</p><h3>What to Expect</h3><pre><code>Learning iteration 50/100
  Mean reward: 1.25        # Positive and increasing = good
  Mean episode length: 299  # Near 300 = robots staying upright</code></pre><p>If rewards are very negative (-20000), the velocity targets are too aggressive - reduce <code>max_lin_vel</code> and <code>max_ang_vel</code>. If episode lengths are short, robots are flipping - reduce <code>action_scale</code>.</p><h2>Exporting the Trained Policy</h2><p>Once training completes, export the model for deployment:</p><pre><code>cd ~/ogre-lab
./scripts/export_policy.sh</code></pre><p>This runs Isaac Lab's <code>play.py</code> to verify the policy works, then exports:</p><ul><li><p><code>policy.onnx</code> - For ONNX runtime inference</p></li><li><p><code>policy.pt</code> - PyTorch JIT format</p></li></ul><p>The exported models go to the training run's <code>exported/</code> directory.</p><h2>Deploying to ROS2</h2><p>Copy the trained model to the ROS2 workspace:</p><pre><code>./scripts/deploy_model.sh --rebuild</code></pre><p>This copies the latest exported model to <code>ogre-slam/ogre_policy_controller/models/</code> and rebuilds the ROS2 package.</p><h3>Testing in Isaac Sim</h3><p><strong>Terminal 1: Isaac Sim</strong></p><ul><li><p>Load <code>ogre.usd</code></p></li><li><p>Press Play</p></li></ul><p><strong>Terminal 2: Policy Controller</strong></p><pre><code>conda deactivate  # Exit conda - ROS2 uses system Python
source ~/ros2_ws/install/setup.bash
ros2 launch ogre_policy_controller policy_controller.launch.py</code></pre><p><strong>Terminal 3: Test Commands</strong></p><pre><code># Forward motion (0.3 m/s)
ros2 topic pub /cmd_vel_smoothed geometry_msgs/msg/Twist \
  "{linear: {x: 0.3, y: 0.0, z: 0.0}, angular: {x: 0.0, y: 0.0, z: 0.0}}" -r 10

# Strafe left
ros2 topic pub /cmd_vel_smoothed geometry_msgs/msg/Twist \
  "{linear: {x: 0.0, y: 0.3, z: 0.0}, angular: {x: 0.0, y: 0.0, z: 0.0}}" -r 10

# Rotate
ros2 topic pub /cmd_vel_smoothed geometry_msgs/msg/Twist \
  "{linear: {x: 0.0, y: 0.0, z: 0.0}, angular: {x: 0.0, y: 0.0, z: 1.0}}" -r 10</code></pre><p>Watch the robot in Isaac Sim. It should move smoothly in the commanded direction.</p><h2>The Data Flow</h2><pre><code>Nav2 Planner &#8594; /cmd_vel &#8594; Policy Controller &#8594; /joint_command &#8594; Isaac Sim/Robot
                               &#8595;
                         policy.onnx
                               &#8595;
                    [FL, FR, RL, RR] wheel velocities</code></pre><p>The policy controller subscribes to <code>/cmd_vel_smoothed</code>, runs the velocity command through the neural network, and publishes individual wheel velocities to <code>/joint_command</code>.</p><h2>Troubleshooting Training</h2><h3>Robots Exploding/Flying</h3><p><strong>Cause</strong>: Physics instability, usually from collision issues or excessive forces</p><p><strong>Solutions</strong>:</p><ul><li><p>Check USD model has proper collision meshes</p></li><li><p>Reduce <code>action_scale</code> (try 6.0 instead of 8.0)</p></li><li><p>Increase physics solver iterations</p></li></ul><h3>Very Negative Rewards</h3><p><strong>Cause</strong>: Velocity targets exceed what the robot can achieve</p><p><strong>Solutions</strong>:</p><ul><li><p>Reduce <code>max_lin_vel</code> and <code>max_ang_vel</code></p></li><li><p>Check <code>action_scale</code> allows sufficient wheel velocity</p></li></ul><h3>Short Episode Lengths</h3><p><strong>Cause</strong>: Robots flipping over</p><p><strong>Solutions</strong>:</p><ul><li><p>Reduce <code>action_scale</code></p></li><li><p>Add stronger uprightness reward</p></li><li><p>Check wheel friction parameters</p></li></ul><h3>Policy Doesn't Generalize</h3><p><strong>Cause</strong>: Training converged to local minimum</p><p><strong>Solutions</strong>:</p><ul><li><p>Increase training iterations</p></li><li><p>Add curriculum learning (start easy, increase difficulty)</p></li><li><p>Tune reward scales</p></li></ul><p>---</p><h2>Quick Reference</h2><h3>Key Files</h3><pre><code>File                       Location                                  Purpose
-------------------------  ----------------------------------------  --------------------
ogre_navigation_env.py     ogre-lab/isaaclab_env/                    Training environment
rsl_rl_ppo_cfg.py          ogre-lab/isaaclab_env/agents/             PPO hyperparameters
policy.onnx                ogre-slam/ogre_policy_controller/models/  Deployed model
policy_controller_node.py  ogre-slam/ogre_policy_controller/         ROS2 inference node</code></pre><h3>Commands</h3><pre><code># Train policy
./scripts/train_ogre_navigation.sh

# Export trained model
./scripts/export_policy.sh

# Deploy to ROS2
./scripts/deploy_model.sh --rebuild

# Run policy controller
ros2 launch ogre_policy_controller policy_controller.launch.py</code></pre><h2>What's Next</h2><p>With a trained policy deployed, <a href="https://protomota.com/blog/project-ogre-part5-nav2-navigation">Part 5</a> brings everything together with Nav2 navigation. The policy controller integrates seamlessly - Nav2 plans paths and generates velocity commands, the RL policy executes them optimally, and the robot navigates autonomously through the mapped environment.</p>]]></content:encoded></item><item><title><![CDATA[Project Ogre Part 3: Building Maps with SLAM]]></title><description><![CDATA[Using slam_toolbox to build occupancy grid maps for autonomous navigation on a mecanum drive robot.]]></description><link>https://newsletter.protomota.com/p/project-ogre-part-3-building-maps</link><guid isPermaLink="false">https://newsletter.protomota.com/p/project-ogre-part-3-building-maps</guid><dc:creator><![CDATA[Brad Dunlap]]></dc:creator><pubDate>Sun, 31 May 2026 19:27:46 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/d73c7eb1-d5bd-4fbf-9417-4ce0db32b4ae_1200x1032.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>Building Maps with SLAM</h2><p>This is Part 3 of the Project Ogre series. In <a href="https://protomota.com/blog/project-ogre-part1-building-robot">Part 1</a>, we built the mecanum drive robot, and in <a href="https://protomota.com/blog/project-ogre-part2-isaac-sim">Part 2</a>, we created a digital twin in Isaac Sim. Now we'll use both to build maps using SLAM (Simultaneous Localization and Mapping).</p><blockquote><p><strong>Project Goal:</strong> Create accurate occupancy grid maps using slam_toolbox that can be used for autonomous navigation with Nav2.</p></blockquote><blockquote><p><strong>&#128230; Full Source Code:</strong> All ROS2 packages and configuration files are available at <a href="https://github.com/protomota/ogre-slam">github.com/protomota/ogre-slam</a></p></blockquote><p>The video below shows SLAM mapping in action. On the left is RViz displaying the occupancy grid map as it's being built in real-time. On the right is Isaac Sim where I'm teleoperating the simulated robot around the maze. As the robot explores, the LIDAR scans are processed by slam_toolbox to incrementally construct the map.</p><p>&#9654; <a href="https://www.youtube.com/watch?v=-61Uhd4AEUM">Watch the video on YouTube</a></p><h2>What is SLAM?</h2><p>SLAM (Simultaneous Localization and Mapping) solves a chicken-and-egg problem: to know where you are, you need a map. To build a map, you need to know where you are. SLAM algorithms solve both simultaneously by matching sensor data against an incrementally-built map.</p><p>For Project Ogre, we use <strong>slam_toolbox</strong> - a robust 2D SLAM implementation that works well with LIDAR sensors. It takes laser scan data from the RPLIDAR and odometry estimates from our encoders, then produces:</p><ol><li><p>A <strong>map</strong> (occupancy grid showing obstacles and free space)</p></li><li><p>A <strong>localization estimate</strong> (where the robot is on that map)</p></li></ol><h2>The Two-Computer Setup</h2><p>Project Ogre uses a split architecture:</p><p><strong>Host Computer (Development)</strong></p><ul><li><p>Runs NVIDIA Isaac Sim for simulation</p></li><li><p>Runs RViz for visualization</p></li><li><p>Handles resource-intensive debugging</p></li></ul><p><strong>Jetson Orin Nano (Robot)</strong></p><ul><li><p>Runs on the physical robot</p></li><li><p>Handles real sensor data</p></li><li><p>Executes navigation commands</p></li></ul><p>Both computers use the same ROS_DOMAIN_ID to communicate over the network. This lets me visualize the robot's SLAM progress in RViz on my development machine while the actual processing happens on the Jetson.</p><h2>Configuring slam_toolbox</h2><p>The SLAM configuration lives in <code>config/slam_toolbox_params.yaml</code>. Here are the key parameters tuned for Project Ogre's hardware:</p><h3>Frame Configuration</h3><pre><code>odom_frame: odom
map_frame: map
base_frame: base_link
scan_topic: /scan
mode: mapping</code></pre><p>These define the TF frames SLAM uses and where it gets laser scan data.</p><h3>Scan Processing</h3><pre><code>transform_publish_period: 0.02    # 50 Hz TF publishing
map_update_interval: 2.0          # Update map every 2 seconds
resolution: 0.05                  # 5cm grid cells
max_laser_range: 12.0             # RPLIDAR A1 max range
minimum_time_interval: 0.2        # Minimum time between scans</code></pre><p>The <code>map_update_interval</code> and <code>resolution</code> are tuned for the Jetson's memory constraints. Publishing the map every 2 seconds instead of continuously reduces CPU load significantly.</p><h3>Critical: Minimum Travel Thresholds</h3><pre><code>minimum_travel_distance: 0.0
minimum_travel_heading: 0.0</code></pre><p>These are set to zero on purpose. By default, slam_toolbox only processes new scans when the robot has moved a minimum distance or rotated a minimum angle. With our noisy 2 PPR encoders, this caused issues - the map would stop updating because odometry drift made SLAM think the robot hadn't moved.</p><p>Setting both to 0.0 forces SLAM to process scans based on time only (<code>minimum_time_interval</code>), not odometry.</p><h3>Scan Matching Parameters</h3><pre><code>correlation_search_space_dimension: 2.0
correlation_search_space_resolution: 0.01
distance_variance_penalty: 0.3
angle_variance_penalty: 0.5</code></pre><p>These control how SLAM matches new scans against the existing map. Lower variance penalties mean "trust scan matching more than odometry." Given our encoder limitations, we want SLAM to rely more heavily on the LIDAR data.</p><h3>Loop Closure</h3><pre><code>do_loop_closing: true
loop_search_maximum_distance: 6.0
loop_match_minimum_response_coarse: 0.45
loop_match_minimum_response_fine: 0.55</code></pre><p>Loop closure happens when the robot revisits a previously-seen location. SLAM recognizes the overlap and corrects accumulated drift. These parameters balance between catching real loop closures and avoiding false positives.</p><h2>Mapping in Isaac Sim</h2><p>Before mapping with the real robot, I develop and test in Isaac Sim. This catches configuration issues without burning through battery life or risking hardware damage.</p><p><strong>Step 1: Start Isaac Sim</strong></p><p>Load the <code>usds/ogre.usd</code> scene and verify the ROS2 Context node has a matching Domain ID. Press Play to start the simulation.</p><p><strong>Step 2: Launch SLAM</strong></p><pre><code>cd ~/ros2_ws
source install/setup.bash

ros2 launch ogre_slam mapping.launch.py \
  use_rviz:=true \
  use_teleop:=false \
  use_odometry:=false \
  use_ekf:=false \
  use_sim_time:=true</code></pre><p>Note the flags:</p><ul><li><p><code>use_sim_time:=true</code> - Sync with Isaac Sim's clock</p></li><li><p><code>use_odometry:=false</code> - Isaac Sim provides odometry directly</p></li><li><p><code>use_ekf:=false</code> - Simulated odometry is clean, no filtering needed</p></li></ul><p><strong>Step 3: Launch the Policy Controller</strong></p><p>The policy controller converts Twist commands to individual wheel velocities:</p><pre><code>ros2 launch ogre_policy_controller policy_controller.launch.py</code></pre><p><strong>Step 4: Drive and Map</strong></p><pre><code>ros2 run teleop_twist_keyboard teleop_twist_keyboard \
  --ros-args -r /cmd_vel:=/cmd_vel_smoothed</code></pre><p>Drive slowly around the environment. Watch RViz as the map builds in real-time. Make sure to create loop closures by revisiting your starting position.</p><p><strong>Step 5: Save the Map</strong></p><pre><code>ros2 run nav2_map_server map_saver_cli \
  -f ~/ros2_ws/src/ogre-slam/maps/isaac_sim_map</code></pre><p>This creates two files:</p><ul><li><p><code>isaac_sim_map.yaml</code> - Metadata (resolution, origin, etc.)</p></li><li><p><code>isaac_sim_map.pgm</code> - The actual map image</p></li></ul><h2>Mapping on the Real Robot</h2><p>Once SLAM works in simulation, deploying to the Jetson is straightforward:</p><p><strong>Step 1: Launch the Mapping Session</strong></p><p>SSH into the robot and run:</p><pre><code>cd ~/ros2_ws/src/ogre-slam
./scripts/launch_mapping_session.sh</code></pre><p>This unified script launches:</p><ul><li><p>RPLIDAR driver</p></li><li><p>Encoder-based odometry node</p></li><li><p>EKF sensor fusion</p></li><li><p>slam_toolbox (mapping mode)</p></li><li><p>Web teleop interface</p></li></ul><p><strong>Step 2: Drive via Web Interface</strong></p><p>Open a browser on any device on the network:</p><pre><code>http://10.21.21.45:8080</code></pre><p>The web interface provides joystick-style control. Drive slowly and smoothly - jerky movements confuse the encoder-based odometry.</p><p><strong>Tips for Good Maps:</strong></p><ul><li><p>Move at consistent, slow speeds</p></li><li><p>Avoid wheel slippage (especially on smooth floors)</p></li><li><p>Create multiple loop closures</p></li><li><p>Drive the same area from different angles</p></li></ul><p><strong>Step 3: Save the Map</strong></p><pre><code>ros2 run nav2_map_server map_saver_cli \
  -f ~/ros2_ws/src/ogre-slam/maps/my_map</code></pre><h2>Remote Visualization</h2><p>Running RViz on the Jetson consumes resources needed for SLAM. Instead, run RViz remotely:</p><p><strong>On the robot:</strong></p><pre><code>ros2 launch ogre_slam mapping.launch.py use_rviz:=false</code></pre><p><strong>On your development machine:</strong></p><pre><code>cd ~/ros2_ws/src/ogre-slam
./scripts/remote_launch_slam_rviz.sh</code></pre><p>The script sets up the ROS_DOMAIN_ID and launches RViz with pre-configured displays for:</p><ul><li><p>LaserScan (<code>/scan</code>)</p></li><li><p>Map (<code>/map</code>)</p></li><li><p>TF frames</p></li><li><p>Robot footprint</p></li></ul><h2>Troubleshooting</h2><h3>Map Not Updating</h3><p><strong>Symptom:</strong> RViz shows an empty or frozen map</p><p><strong>Cause:</strong> Usually odometry issues. slam_toolbox needs meaningful position changes to trigger scan processing.</p><p><strong>Solution:</strong></p><ol><li><p>Verify <code>/odom</code> shows changing positions when driving:</p></li></ol><pre><code>   ros2 topic echo /odom --once | grep -A 3 "position"</code></pre><ol><li><p>Check gear_ratio calibration (Part 1)</p></li><li><p>Ensure <code>minimum_travel_distance: 0.0</code> in config</p></li></ol><h3>SLAM Losing Track</h3><p><strong>Symptom:</strong> Map suddenly rotates or shifts incorrectly</p><p><strong>Cause:</strong> Scan matching failed, possibly from driving too fast or odometry drift</p><p><strong>Solution:</strong></p><ol><li><p>Drive slower</p></li><li><p>Increase <code>scan_buffer_size</code> for more reference scans</p></li><li><p>Lower <code>link_match_minimum_response_fine</code> to accept weaker matches</p></li></ol><h3>High CPU Usage</h3><p><strong>Symptom:</strong> Jetson running hot, sluggish response</p><p><strong>Solution:</strong></p><ol><li><p>Increase <code>map_update_interval</code> (try 3.0 or 5.0 seconds)</p></li><li><p>Increase <code>resolution</code> (0.10 instead of 0.05)</p></li><li><p>Disable RViz on the Jetson</p></li></ol><h3>No Loop Closure</h3><p><strong>Symptom:</strong> Drift accumulates, map doesn't correct when revisiting areas</p><p><strong>Solution:</strong></p><ol><li><p>Ensure <code>do_loop_closing: true</code></p></li><li><p>Lower <code>loop_match_minimum_response_coarse</code> to detect more candidates</p></li><li><p>Increase <code>loop_search_maximum_distance</code> for wider search</p></li></ol><h2>Understanding the Map Output</h2><p>This is the 2D SLAM map from the maze I created for my robot within Isaac Sim:</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!2jXQ!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd73c7eb1-d5bd-4fbf-9417-4ce0db32b4ae_1200x1032.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!2jXQ!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd73c7eb1-d5bd-4fbf-9417-4ce0db32b4ae_1200x1032.png 424w, https://substackcdn.com/image/fetch/$s_!2jXQ!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd73c7eb1-d5bd-4fbf-9417-4ce0db32b4ae_1200x1032.png 848w, https://substackcdn.com/image/fetch/$s_!2jXQ!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd73c7eb1-d5bd-4fbf-9417-4ce0db32b4ae_1200x1032.png 1272w, https://substackcdn.com/image/fetch/$s_!2jXQ!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd73c7eb1-d5bd-4fbf-9417-4ce0db32b4ae_1200x1032.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!2jXQ!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd73c7eb1-d5bd-4fbf-9417-4ce0db32b4ae_1200x1032.png" width="728" height="626.08" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/d73c7eb1-d5bd-4fbf-9417-4ce0db32b4ae_1200x1032.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:false,&quot;imageSize&quot;:&quot;normal&quot;,&quot;height&quot;:1032,&quot;width&quot;:1200,&quot;resizeWidth&quot;:728,&quot;bytes&quot;:28424,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!2jXQ!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd73c7eb1-d5bd-4fbf-9417-4ce0db32b4ae_1200x1032.png 424w, https://substackcdn.com/image/fetch/$s_!2jXQ!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd73c7eb1-d5bd-4fbf-9417-4ce0db32b4ae_1200x1032.png 848w, https://substackcdn.com/image/fetch/$s_!2jXQ!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd73c7eb1-d5bd-4fbf-9417-4ce0db32b4ae_1200x1032.png 1272w, https://substackcdn.com/image/fetch/$s_!2jXQ!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd73c7eb1-d5bd-4fbf-9417-4ce0db32b4ae_1200x1032.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>The saved map consists of:</p><p><strong>isaac_sim_map.yaml:</strong></p><pre><code>image: isaac_sim_map.pgm
mode: trinary
resolution: 0.05
origin: [-10.0, -10.0, 0.0]
negate: 0
occupied_thresh: 0.65
free_thresh: 0.25</code></pre><p><strong>isaac_sim_map.pgm:</strong> A grayscale image where:</p><ul><li><p><strong>White (255)</strong>: Free space - safe to traverse</p></li><li><p><strong>Black (0)</strong>: Occupied - obstacles detected</p></li><li><p><strong>Gray (205)</strong>: Unknown - not yet observed</p></li></ul><p>The <code>resolution</code> (0.05) means each pixel represents a 5cm &#215; 5cm area. The <code>origin</code> defines where the map's (0,0) pixel is in world coordinates.</p><p>---</p><h2>Quick Reference</h2><h3>ROS2 Topics</h3><pre><code>Topic               Purpose
------------------  -----------------------------------
/scan               RPLIDAR laser scans (input to SLAM)
/odom               Raw wheel odometry
/odometry/filtered  EKF-smoothed odometry
/map                Output occupancy grid</code></pre><h3>Commands</h3><pre><code># Launch mapping (simulation)
ros2 launch ogre_slam mapping.launch.py use_sim_time:=true

# Launch mapping (real robot)
./scripts/launch_mapping_session.sh

# Save map
ros2 run nav2_map_server map_saver_cli -f ~/ros2_ws/src/ogre-slam/maps/my_map

# Check map publishing rate
ros2 topic hz /map</code></pre><h2>What's Next</h2><p>With a map saved, the next step is Nav2 integration - but there's a problem. Nav2 sends velocity commands assuming the robot will execute them perfectly. Mecanum wheels are difficult to control precisely. The angled rollers that enable omnidirectional movement also introduce wheel slip, and each motor responds slightly differently. Simple kinematic equations don't account for these real-world factors. None of this crossed my mind when I picked up what looked like a cool robot kit. It is cool - just significantly more complicated than expected.</p><p><a href="https://protomota.com/blog/project-ogre-part4-isaac-lab-training">Part 4</a> covers training a reinforcement learning policy in NVIDIA Isaac Lab. The neural network learns the robot's actual dynamics through millions of simulated trials - compensating for wheel slip, uneven floors, and motor response differences. Setting up the Isaac Lab environment, defining reward functions, tuning hyperparameters, and iterating through training runs was a significant undertaking.</p><p>The trained policy sits between Nav2's velocity commands and the wheel motors, translating "go forward at 0.5 m/s" into the precise PWM signals each wheel needs to achieve that motion. Without it, the robot drifts, overshoots, and struggles to follow planned paths accurately.</p>]]></content:encoded></item><item><title><![CDATA[Project Ogre Part 2: Building the Digital Twin in Isaac Sim]]></title><description><![CDATA[Creating an accurate digital twin of the mecanum drive robot in NVIDIA Isaac Sim for simulation-first development and testing.]]></description><link>https://newsletter.protomota.com/p/project-ogre-part-2-building-the</link><guid isPermaLink="false">https://newsletter.protomota.com/p/project-ogre-part-2-building-the</guid><dc:creator><![CDATA[Brad Dunlap]]></dc:creator><pubDate>Sun, 31 May 2026 19:27:25 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/956cfcc5-e9fd-4ee9-8af4-e96dcfd7d238_1200x1042.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>Building the Digital Twin</h2><p>This is Part 2 of the Project Ogre series. In <a href="https://protomota.com/blog/project-ogre-part1-building-robot">Part 1</a>, we built the physical mecanum drive robot. Now we'll create an accurate digital twin in NVIDIA Isaac Sim that lets us develop and test without touching the real hardware.</p><blockquote><p><strong>Project Goal:</strong> Build an accurate simulation of Project Ogre in Isaac Sim that uses the same ROS2 interface as the real robot, enabling simulation-first development.</p></blockquote><blockquote><p><strong>&#128230; Full Source Code:</strong> The USD robot model and Isaac Sim configuration are available at <a href="https://github.com/protomota/ogre-slam">github.com/protomota/ogre-slam</a></p></blockquote><h2>Why Simulation First?</h2><p>In an ideal workflow, you'd build the simulation before touching hardware. I was too eager to get a working robot, so I built the physical platform first. I made progress on the real robot, but eventually hit enough issues that I realized constructing a digital twin would make debugging much easier.</p><p>The benefits of simulation-first development:</p><ul><li><p><strong>No hardware damage risk</strong> - Test aggressive maneuvers without breaking motors or sensors</p></li><li><p><strong>Faster iteration</strong> - Reset the simulation in milliseconds vs. repositioning a physical robot</p></li><li><p><strong>Reproducible experiments</strong> - Same initial conditions every time</p></li><li><p><strong>Parallel development</strong> - Work on navigation code while the hardware is being built</p></li></ul><p>Isaac Sim is NVIDIA's robotics simulation platform built on Omniverse. It provides:</p><ul><li><p>GPU-accelerated physics (PhysX 5)</p></li><li><p>Photorealistic rendering with ray tracing</p></li><li><p>Native ROS2 integration</p></li><li><p>Sensor simulation (LIDAR, cameras, IMU)</p></li></ul><h2>The USD Robot Model</h2><p>The robot is defined in a Universal Scene Description (USD) file: <code>usds/ogre_robot.usd</code>. This format captures geometry, physics properties, and joint definitions in a single file.</p><h3>Creating the Model</h3><p>I built the USD model by:</p><ol><li><p><strong>Importing the chassis</strong> - A simple box representing the steel frame</p></li><li><p><strong>Adding wheel assemblies</strong> - Four mecanum wheels with proper roller geometry</p></li><li><p><strong>Defining joints</strong> - Revolute joints connecting each wheel to the chassis</p></li><li><p><strong>Setting up collision meshes</strong> - Simplified geometry for physics calculations</p></li><li><p><strong>Adding sensors</strong> - LIDAR and camera mounts</p></li></ol><h3>Physical Dimensions</h3><p>The USD model matches the real robot's measurements exactly:</p><pre><code>Body: 200mm (L) x 160mm (W) x 175mm (H)
Wheel Radius: 40mm
Wheelbase: 95mm (front-to-rear axle distance)
Track Width: 205mm (left-to-right wheel centers)</code></pre><p>Getting these dimensions right is critical - SLAM and navigation algorithms depend on accurate odometry, which depends on knowing exactly how far the wheels travel per rotation.</p><h3>Wheel Configuration</h3><p>Mecanum wheels require careful attention to roller angles:</p><pre><code>M4 (FL) --- M1 (FR)
   |    X    |
M3 (RL) --- M2 (RR)</code></pre><ul><li><p>Front-left and rear-right wheels have rollers angled one way</p></li><li><p>Front-right and rear-left wheels have rollers angled the opposite way</p></li></ul><p>This arrangement creates the characteristic X-pattern when viewed from above. If the roller angles are wrong, the robot won't strafe properly.</p><h2>Joint Configuration</h2><p>Each wheel joint needs proper physics parameters for stable simulation:</p><h3>Joint Properties</h3><pre><code># Wheel joint configuration
joint_type: "revolute"
axis: [1, 0, 0]  # Rotate around X-axis
damping: 10.0
friction: 0.1</code></pre><h3>Critical: Maximum Joint Velocity</h3><p>Isaac Sim's default maximum joint velocity is extremely high (1,000,000 rad/s). This causes mecanum wheels to spin out of control during physics steps.</p><p><strong>Solution:</strong> Set maximum velocity to 10,000 rad/s or lower:</p><pre><code>drive:
  maxVelocity: 10000.0  # NOT the default 1000000!</code></pre><p>This single parameter took hours to debug. The robot would work fine at low speeds but become unstable at higher velocities.</p><h3>Wheel Sign Correction</h3><p>The USD model has the right wheels (FR, RR) with opposite joint axis orientation from the left wheels. Sending <code>[+10, +10, +10, +10]</code> to all wheels makes the robot spin instead of moving forward.</p><p>The solution is sign correction in the action graph:</p><pre><code>Left wheels (FL, RL): velocity as-is
Right wheels (FR, RR): velocity &#215; -1</code></pre><p>This normalization ensures <code>[+,+,+,+]</code> always means "all wheels forward" regardless of joint axis conventions.</p><h2>Physics Settings</h2><p>Stable mecanum simulation requires tuned physics parameters:</p><h3>Simulation Timestep</h3><pre><code>physics_dt: 1/120  # 120 Hz physics
rendering_dt: 1/60  # 60 Hz rendering</code></pre><p>The physics timestep must be faster than your control rate. With 30 Hz control, 120 Hz physics gives four physics steps per control step.</p><h3>Wheel Friction</h3><p>Mecanum wheel friction is counterintuitive. The angled rollers mean friction behaves differently along different axes:</p><pre><code># Ground material
static_friction: 0.8
dynamic_friction: 0.6

# Wheel material
static_friction: 0.5
dynamic_friction: 0.4</code></pre><p>Too much friction and the wheels won't slide sideways. Too little and the robot drifts uncontrollably.</p><h3>Joint Damping</h3><pre><code>damping: 1.0 to 10.0  # Range for stability</code></pre><p>Damping prevents joint oscillation. Higher values make the wheels feel "heavier" but more stable. Start with 10.0 and reduce if response feels sluggish.</p><h2>ROS2 Integration</h2><p>Isaac Sim connects to ROS2 through Action Graphs - visual programming nodes that define data flow.</p><h3>Setting Up the ROS2 Context</h3><p>Every Isaac Sim scene with ROS2 needs a context node:</p><ol><li><p>Create an Action Graph</p></li><li><p>Add "ROS2 Context" node</p></li><li><p>Set <code>domain_id</code> to match your terminals (default: 0)</p></li></ol><p>The domain ID must match between Isaac Sim and your ROS2 terminals, or topics won't be visible.</p><h3>Publishing Joint States</h3><p>To publish wheel positions and velocities:</p><ol><li><p>Add "Articulation State" node pointing to the robot</p></li><li><p>Connect to "ROS2 Publish Joint State" node</p></li><li><p>Set topic name: <code>/joint_states</code></p></li></ol><h3>Subscribing to Commands</h3><p>To receive wheel velocity commands:</p><ol><li><p>Add "ROS2 Subscribe Joint State" node</p></li><li><p>Set topic: <code>/joint_command</code></p></li><li><p>Connect to "Articulation Controller" node</p></li><li><p>Set control type: "velocity"</p></li></ol><h2>The Three Action Graphs</h2><p>Rather than cramming everything into one monolithic action graph, I split the ROS2 integration into three focused graphs. Each handles a specific subsystem, making debugging easier and keeping the visual programming manageable.</p><h3>Mecanum Drive Controller</h3><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!GEFN!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F71648f66-eba3-428c-8743-7684e5859cc4_1200x1162.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!GEFN!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F71648f66-eba3-428c-8743-7684e5859cc4_1200x1162.png 424w, https://substackcdn.com/image/fetch/$s_!GEFN!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F71648f66-eba3-428c-8743-7684e5859cc4_1200x1162.png 848w, https://substackcdn.com/image/fetch/$s_!GEFN!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F71648f66-eba3-428c-8743-7684e5859cc4_1200x1162.png 1272w, https://substackcdn.com/image/fetch/$s_!GEFN!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F71648f66-eba3-428c-8743-7684e5859cc4_1200x1162.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!GEFN!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F71648f66-eba3-428c-8743-7684e5859cc4_1200x1162.png" width="728" height="704.9466666666667" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/71648f66-eba3-428c-8743-7684e5859cc4_1200x1162.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:false,&quot;imageSize&quot;:&quot;normal&quot;,&quot;height&quot;:1162,&quot;width&quot;:1200,&quot;resizeWidth&quot;:728,&quot;bytes&quot;:243164,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!GEFN!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F71648f66-eba3-428c-8743-7684e5859cc4_1200x1162.png 424w, https://substackcdn.com/image/fetch/$s_!GEFN!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F71648f66-eba3-428c-8743-7684e5859cc4_1200x1162.png 848w, https://substackcdn.com/image/fetch/$s_!GEFN!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F71648f66-eba3-428c-8743-7684e5859cc4_1200x1162.png 1272w, https://substackcdn.com/image/fetch/$s_!GEFN!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F71648f66-eba3-428c-8743-7684e5859cc4_1200x1162.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>This is the core locomotion graph that handles bidirectional communication between ROS2 and the robot's wheels.</p><p><strong>What it does:</strong></p><ul><li><p>Subscribes to <code>/joint_command</code> for wheel velocity targets</p></li><li><p>Applies wheel sign correction (FR, RR &#215; -1) to normalize joint axis conventions</p></li><li><p>Drives the Articulation Controller with velocity commands</p></li><li><p>Publishes current joint states to <code>/joint_states</code> for odometry feedback</p></li></ul><p><strong>Key nodes:</strong></p><ul><li><p><code>ROS2 Context</code> - Establishes the ROS2 bridge with matching domain ID</p></li><li><p><code>ROS2 Subscribe Joint State</code> - Receives velocity commands from the policy controller</p></li><li><p><code>Articulation Controller</code> - Applies velocities to the simulated joints</p></li><li><p><code>Articulation State</code> - Reads current joint positions and velocities</p></li><li><p><code>ROS2 Publish Joint State</code> - Broadcasts state for external nodes</p></li></ul><p>The sign correction happens between the subscriber and the articulation controller. Without it, sending identical velocities to all wheels makes the robot spin instead of driving straight.</p><h3>LIDAR Sensor Stream</h3><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!PzfH!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5e6b8f72-9e99-45fb-9415-000a231a972d_1200x1689.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!PzfH!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5e6b8f72-9e99-45fb-9415-000a231a972d_1200x1689.png 424w, https://substackcdn.com/image/fetch/$s_!PzfH!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5e6b8f72-9e99-45fb-9415-000a231a972d_1200x1689.png 848w, https://substackcdn.com/image/fetch/$s_!PzfH!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5e6b8f72-9e99-45fb-9415-000a231a972d_1200x1689.png 1272w, https://substackcdn.com/image/fetch/$s_!PzfH!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5e6b8f72-9e99-45fb-9415-000a231a972d_1200x1689.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!PzfH!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5e6b8f72-9e99-45fb-9415-000a231a972d_1200x1689.png" width="728" height="1024.66" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/5e6b8f72-9e99-45fb-9415-000a231a972d_1200x1689.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:false,&quot;imageSize&quot;:&quot;normal&quot;,&quot;height&quot;:1689,&quot;width&quot;:1200,&quot;resizeWidth&quot;:728,&quot;bytes&quot;:379209,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!PzfH!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5e6b8f72-9e99-45fb-9415-000a231a972d_1200x1689.png 424w, https://substackcdn.com/image/fetch/$s_!PzfH!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5e6b8f72-9e99-45fb-9415-000a231a972d_1200x1689.png 848w, https://substackcdn.com/image/fetch/$s_!PzfH!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5e6b8f72-9e99-45fb-9415-000a231a972d_1200x1689.png 1272w, https://substackcdn.com/image/fetch/$s_!PzfH!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5e6b8f72-9e99-45fb-9415-000a231a972d_1200x1689.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>This graph handles the simulated RPLIDAR A1 sensor, streaming 2D laser scan data to SLAM and navigation nodes.</p><p><strong>What it does:</strong></p><ul><li><p>Reads from the RTX Lidar sensor attached to the robot</p></li><li><p>Converts the point cloud to a 2D LaserScan message</p></li><li><p>Publishes to <code>/scan</code> at the configured rate (~8 Hz to match the real A1)</p></li></ul><p><strong>Key nodes:</strong></p><ul><li><p><code>Isaac Read Lidar Point Cloud</code> - Pulls data from the RTX Lidar sensor</p></li><li><p><code>Isaac Create Render Product</code> - Sets up the rendering pipeline for the sensor</p></li><li><p><code>ROS2 Publish LaserScan</code> - Outputs sensor_msgs/LaserScan to <code>/scan</code></p></li></ul><p><strong>Configuration:</strong></p><pre><code>horizontal_fov: 360.0      # Full rotation
horizontal_resolution: 1.0  # 1 degree per sample
max_range: 12.0            # Matches RPLIDAR A1 spec
min_range: 0.15            # Minimum detection distance</code></pre><p>The simulated LIDAR matches the real sensor's characteristics closely enough that slam_toolbox configuration works identically in both environments.</p><h3>RealSense Depth Camera</h3><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!RXNX!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe87c6248-ad80-4300-b5e7-a66d59b8b1bb_1200x1741.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!RXNX!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe87c6248-ad80-4300-b5e7-a66d59b8b1bb_1200x1741.png 424w, https://substackcdn.com/image/fetch/$s_!RXNX!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe87c6248-ad80-4300-b5e7-a66d59b8b1bb_1200x1741.png 848w, https://substackcdn.com/image/fetch/$s_!RXNX!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe87c6248-ad80-4300-b5e7-a66d59b8b1bb_1200x1741.png 1272w, https://substackcdn.com/image/fetch/$s_!RXNX!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe87c6248-ad80-4300-b5e7-a66d59b8b1bb_1200x1741.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!RXNX!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe87c6248-ad80-4300-b5e7-a66d59b8b1bb_1200x1741.png" width="728" height="1056.2066666666667" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/e87c6248-ad80-4300-b5e7-a66d59b8b1bb_1200x1741.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:false,&quot;imageSize&quot;:&quot;normal&quot;,&quot;height&quot;:1741,&quot;width&quot;:1200,&quot;resizeWidth&quot;:728,&quot;bytes&quot;:263126,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!RXNX!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe87c6248-ad80-4300-b5e7-a66d59b8b1bb_1200x1741.png 424w, https://substackcdn.com/image/fetch/$s_!RXNX!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe87c6248-ad80-4300-b5e7-a66d59b8b1bb_1200x1741.png 848w, https://substackcdn.com/image/fetch/$s_!RXNX!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe87c6248-ad80-4300-b5e7-a66d59b8b1bb_1200x1741.png 1272w, https://substackcdn.com/image/fetch/$s_!RXNX!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe87c6248-ad80-4300-b5e7-a66d59b8b1bb_1200x1741.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>This graph simulates the Intel RealSense D435 depth camera, providing both RGB and depth streams.</p><p><strong>What it does:</strong></p><ul><li><p>Captures RGB frames from the simulated camera</p></li><li><p>Generates depth images from the camera's depth sensor</p></li><li><p>Publishes synchronized image streams to standard RealSense topics</p></li></ul><p><strong>Published topics:</strong></p><ul><li><p><code>/camera/color/image_raw</code> - RGB image (sensor_msgs/Image)</p></li><li><p><code>/camera/depth/image_rect_raw</code> - Depth image (sensor_msgs/Image)</p></li><li><p><code>/camera/camera_info</code> - Camera intrinsics for 3D reconstruction</p></li></ul><p><strong>Key nodes:</strong></p><ul><li><p><code>Isaac Create Render Product</code> - Sets up the camera rendering pipeline</p></li><li><p><code>ROS2 Camera Helper</code> - Handles image encoding and publishing</p></li><li><p><code>Isaac Read Camera Info</code> - Extracts camera parameters</p></li></ul><p><strong>Configuration:</strong></p><pre><code>resolution: [640, 480]
frame_rate: 30
depth_range: [0.2, 10.0]  # Matches D435 spec</code></pre><p>The depth stream feeds into Nav2's voxel layer for 3D obstacle detection - critical for spotting obstacles below the LIDAR's scan plane like table legs or stairs.</p><h3>Why Three Separate Graphs?</h3><p>Splitting into three graphs provides several advantages:</p><ol><li><p><strong>Isolation</strong> - A bug in the camera graph doesn't affect locomotion</p></li><li><p><strong>Performance</strong> - Each graph can run at its optimal rate (wheels at 50Hz, LIDAR at 8Hz, camera at 30Hz)</p></li><li><p><strong>Debugging</strong> - Easy to disable one sensor while testing another</p></li><li><p><strong>Reusability</strong> - The mecanum drive graph works for any 4-wheeled robot</p></li></ol><p>You could combine them into one graph, but the visual complexity becomes overwhelming quickly. Three focused graphs are much easier to maintain.</p><h2>Testing the Digital Twin</h2><p>Once the model is configured, test it incrementally:</p><h3>Step 1: Basic Motion</h3><pre><code># Terminal 1: Launch Isaac Sim, load ogre.usd, press Play

# Terminal 2: Send a velocity command
ros2 topic pub /joint_command sensor_msgs/msg/JointState \
  "{velocity: [1.0, 1.0, 1.0, 1.0]}" -r 10</code></pre><p>All wheels should spin. If the robot spins in place instead of moving forward, check the wheel sign correction.</p><h3>Step 2: Omnidirectional Motion</h3><pre><code># Forward
ros2 topic pub /cmd_vel geometry_msgs/msg/Twist \
  "{linear: {x: 0.3}}" -r 10

# Strafe left
ros2 topic pub /cmd_vel geometry_msgs/msg/Twist \
  "{linear: {y: 0.3}}" -r 10

# Rotate
ros2 topic pub /cmd_vel geometry_msgs/msg/Twist \
  "{angular: {z: 0.5}}" -r 10</code></pre><p>Test all motion primitives. The strafe test is the true mecanum validation - if it moves diagonally instead of purely sideways, check the wheel arrangement.</p><h3>Step 3: Sensor Verification</h3><pre><code># Check LIDAR
ros2 topic echo /scan --once

# Visualize in RViz
rviz2 -d ~/ros2_ws/src/ogre-slam/rviz/isaac_sim.rviz</code></pre><p>The LIDAR should show walls and obstacles in the simulation environment.</p><h2>Simulation vs. Real Robot</h2><p>The key advantage of this setup: <strong>the same ROS2 code runs in both environments</strong>.</p><pre><code>Component       Simulation         Real Robot
--------------  -----------------  --------------
LIDAR topic     /scan              /scan
Odometry        Isaac Sim physics  Encoder-based
Camera          Simulated RGB-D    RealSense D435
Joint commands  /joint_command     /joint_command</code></pre><p>The only difference is launch parameters. The same SLAM, navigation, and control code works in both places.</p><h3>Simulation-Only Flags</h3><pre><code># Simulation
ros2 launch ogre_slam mapping.launch.py use_sim_time:=true

# Real robot
ros2 launch ogre_slam mapping.launch.py use_sim_time:=false</code></pre><p>The <code>use_sim_time</code> flag tells ROS2 to use Isaac Sim's clock instead of wall clock. Without this, TF transforms time out because simulation time doesn't match real time.</p><h2>Common Issues</h2><h3>Robot Falls Through Floor</h3><p><strong>Cause:</strong> Collision meshes not enabled or ground plane missing</p><p><strong>Solution:</strong></p><ol><li><p>Ensure robot has collision enabled on all rigid bodies</p></li><li><p>Add a ground plane with collision</p></li></ol><h3>Wheels Spin But Robot Doesn't Move</h3><p><strong>Cause:</strong> Wheel friction too low or joint damping too high</p><p><strong>Solution:</strong></p><ol><li><p>Increase ground friction</p></li><li><p>Reduce joint damping</p></li><li><p>Check wheel contact with ground</p></li></ol><h3>Robot Vibrates or Explodes</h3><p><strong>Cause:</strong> Physics instability from timestep or mass issues</p><p><strong>Solution:</strong></p><ol><li><p>Reduce physics timestep (increase Hz)</p></li><li><p>Check mass distribution is reasonable</p></li><li><p>Reduce joint stiffness</p></li></ol><h3>Topics Not Visible</h3><p><strong>Cause:</strong> ROS_DOMAIN_ID mismatch</p><p><strong>Solution:</strong></p><ol><li><p>Check Isaac Sim ROS2 Context domain_id</p></li><li><p>Match in terminal: <code>export ROS_DOMAIN_ID=0</code></p></li></ol><p>---</p><h2>Quick Reference</h2><h3>Key Files</h3><pre><code>File                                   Purpose
-------------------------------------  ----------------------------------------
usds/ogre_robot.usd                    Robot model with physics
usds/ogre.usd                          Complete scene with environment
actiongraph_ros2_mecanum_drive_sm      Wheel control and joint state publishing
actiongraph_ros2_lidar_stream_sm       LIDAR sensor simulation
actiongraph_ros2_real_sense_camera_sm  RGB-D camera simulation
rviz/isaac_sim.rviz                    RViz config for simulation visualization</code></pre><h3>Isaac Sim Settings</h3><pre><code>Physics:
  timestep: 1/120 (120 Hz)
  solver_iterations: 4

Wheel Joints:
  max_velocity: 10000 rad/s
  damping: 10.0

Ground:
  static_friction: 0.8
  dynamic_friction: 0.6</code></pre><h2>What's Next</h2><p>With the digital twin working, <a href="https://protomota.com/blog/project-ogre-part3-slam-mapping">Part 3</a> covers using slam_toolbox to build maps. We'll run SLAM in both simulation and on the real robot, comparing results and tuning parameters for accurate mapping.</p><p>The ability to test SLAM in simulation before deploying to hardware saves significant time - you can iterate on configuration without draining the robot's battery or worrying about collisions.</p>]]></content:encoded></item><item><title><![CDATA[Project Ogre Part 1: Building a Mecanum Drive Robot with Jetson Orin Nano]]></title><description><![CDATA[Building a 4-wheel mecanum drive robot from scratch with NVIDIA Jetson Orin Nano, RPLIDAR, and RealSense depth camera for autonomous navigation.]]></description><link>https://newsletter.protomota.com/p/project-ogre-part-1-building-a-mecanum</link><guid isPermaLink="false">https://newsletter.protomota.com/p/project-ogre-part-1-building-a-mecanum</guid><dc:creator><![CDATA[Brad Dunlap]]></dc:creator><pubDate>Sun, 31 May 2026 19:26:58 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/1bf39209-cfc1-46c0-9e0f-ba2bc90e8e7f_1200x900.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>About Project Ogre</h2><p>Project Ogre is a mecanum drive robot platform designed for autonomous navigation using ROS2 and NVIDIA's robotics ecosystem. The name comes from the robot's stocky, powerful appearance with its four omnidirectional wheels. This is the first post in a five-part series covering the complete build:</p><ol><li><p><strong>Part 1: Building the Robot</strong> (this post)</p></li><li><p><a href="https://protomota.com/blog/project-ogre-part2-isaac-sim">Part 2: Building the Digital Twin in Isaac Sim</a></p></li><li><p><a href="https://protomota.com/blog/project-ogre-part3-slam-mapping">Part 3: SLAM Mapping with slam_toolbox</a></p></li><li><p><a href="https://protomota.com/blog/project-ogre-part4-isaac-lab-training">Part 4: Training in Isaac Lab</a></p></li><li><p><a href="https://protomota.com/blog/project-ogre-part5-nav2-navigation">Part 5: Autonomous Navigation with Nav2</a></p></li></ol><blockquote><p><strong>Project Goal:</strong> Build a mecanum drive robot powered by the Jetson Orin Nano 8GB Developer Kit, running entirely from a single battery, capable of omnidirectional movement, SLAM mapping, and autonomous waypoint navigation using ROS2 Nav2.</p></blockquote><blockquote><p><strong>&#128230; Full Source Code:</strong> The teleoperation code we ran on the Jetson Orin Nano to test the motors and camera is available at <a href="https://github.com/protomota/ogre-teleop">github.com/protomota/ogre-teleop</a></p></blockquote><h2>Why Mecanum Wheels?</h2><p>Honestly, I wasn't specifically looking for mecanum wheels. I wanted powerful motors with encoders for a 4-wheel drive robot - something that could power over small bumps and floor transitions where my JetBot would struggle. I was originally planning to just 3D print some standard wheels.</p><p>But while browsing for motors, I found a kit that changed my plans: four motors with encoders, four mecanum wheels pre-fitted, and a simple steel platform to build upon. The price was right, and it gave me a solid foundation to work with. So mecanum it was.</p><p>The bonus? Mecanum wheels unlock omnidirectional movement. Traditional differential drive robots can only move forward, backward, and rotate. With mecanum wheels, the robot can strafe sideways, move diagonally, and rotate while translating - all thanks to rollers mounted at 45-degree angles on each wheel.</p><p>The key insight is that when all four wheels spin together, the angled rollers create force vectors that can be combined to produce motion in any direction:</p><ul><li><p><strong>All wheels forward</strong> &#8594; Robot moves forward</p></li><li><p><strong>Left wheels backward, right wheels forward</strong> &#8594; Robot rotates</p></li><li><p><strong>Front wheels inward, rear wheels outward</strong> &#8594; Robot strafes left</p></li></ul><p>This flexibility turned out to be invaluable for navigation in tight spaces, though it does add complexity to both hardware and software.</p><p>&#9654; <a href="https://www.youtube.com/watch?v=jGxUyQQaRAo">Watch the video on YouTube</a></p><h2>Hardware Components</h2><h3>Core Computing</h3><ul><li><p><strong>NVIDIA Jetson Orin Nano Developer Kit (8GB)</strong> - The robot's brain. The Orin Nano provides significant computing power for running SLAM, Nav2, and sensor processing simultaneously. At 25W power mode, it handles everything smoothly.</p></li></ul><h3>Drive System</h3><ul><li><p><strong>4x 25GA-370 DC Motors with Encoders</strong> - These geared motors provide the torque needed to move the robot. Each motor includes a 2 PPR (pulses per revolution) Hall effect encoder for odometry.</p></li></ul><ul><li><p><strong>4x Mecanum Wheels (40mm radius)</strong> - The omnidirectional wheels that give the robot its unique mobility. Pay attention to the roller angles - you need two left-handed and two right-handed wheels arranged correctly.</p></li></ul><ul><li><p><strong>PCA9685 Motor Driver</strong> - A 16-channel PWM driver controlled via I2C. This handles motor speed control without consuming GPIO pins.</p></li></ul><h3>Sensors</h3><ul><li><p><strong>RPLIDAR A1</strong> - A 360-degree 2D LIDAR scanner. It provides laser scan data for SLAM mapping and localization. The A1 is an excellent entry-level option with 12-meter range at ~8Hz scan rate.</p></li></ul><ul><li><p><strong>Intel RealSense D435</strong> - A depth camera for 3D obstacle detection. While the LIDAR handles 2D mapping, the RealSense provides point cloud data for detecting obstacles below the LIDAR's scan plane (like table legs or stairs).</p></li></ul><h3>Physical Dimensions</h3><p>These measurements are critical for accurate odometry and SLAM:</p><pre><code>Body: 200mm (L) x 160mm (W) x 175mm (H)
Wheel Radius: 40mm
Wheelbase: 95mm (front-to-rear axle distance)
Track Width: 205mm (left-to-right wheel centers)
Total Footprint: ~310mm x 205mm x 300mm
Robot Weight: ~5kg (10 lb 15 oz)</code></pre><p>The robot sits with its body positioned 20mm above the wheel axle height. The LIDAR is mounted on 65mm posts, placing it at 0.30m above the base_link frame.</p><h2>System Architecture</h2><p>The robot is organized into five distinct layers, from sensors at the top to the chassis at the bottom:</p><pre><code>&#9484;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9488;
&#9474;                        SENSOR LAYER (Top)                           &#9474;
&#9474;  &#9484;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9488;   &#9484;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9488;   &#9484;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9488;  &#9474;
&#9474;  &#9474;  RPLIDAR A1  &#9474;   &#9474;  Intel RealSense    &#9474;   &#9474;   LiPo Battery   &#9474;  &#9474;
&#9474;  &#9474;              &#9474;   &#9474;       D435          &#9474;   &#9474;     Monitor      &#9474;  &#9474;
&#9474;  &#9492;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9496;   &#9492;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9496;   &#9492;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9496;  &#9474;
&#9492;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9496;
                                 &#9474;
                                 &#9660;
&#9484;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9488;
&#9474;                         LOGIC LAYER                                 &#9474;
&#9474;  &#9484;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9488;  &#9484;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9488;  &#9484;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9488;  &#9474;
&#9474;  &#9474;   Jetson Orin Nano     &#9474;  &#9474;   PCA9685   &#9474;  &#9474;  5V &#8594; 3.3V       &#9474;  &#9474;
&#9474;  &#9474;         8GB            &#9474;  &#9474;     PWM     &#9474;  &#9474;  Logic Level     &#9474;  &#9474;
&#9474;  &#9474;    (Main Controller)   &#9474;  &#9474;    Driver   &#9474;  &#9474;  Converter       &#9474;  &#9474;
&#9474;  &#9492;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9496;  &#9492;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9496;  &#9492;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9496;  &#9474;
&#9492;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9496;
                                 &#9474;
                                 &#9660;
&#9484;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9488;
&#9474;                         POWER LAYER                                 &#9474;
&#9474;  &#9484;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9488;   &#9484;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9488;   &#9474;
&#9474;  &#9474;   6S LiPo Battery  &#9474;   &#9474;   Buck Converters &amp; Power           &#9474;   &#9474;
&#9474;  &#9474;                    &#9474;   &#9474;        Conditioner                  &#9474;   &#9474;
&#9474;  &#9492;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9496;   &#9492;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9496;   &#9474;
&#9492;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9496;
                                 &#9474;
                                 &#9660;
&#9484;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9488;
&#9474;                    MOTOR CONTROLLER LAYER                           &#9474;
&#9474;  &#9484;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9488; &#9484;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9488; &#9484;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9488; &#9484;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9488;  &#9474;
&#9474;  &#9474;   Motor      &#9474; &#9474;   Motor      &#9474; &#9474;   Motor      &#9474; &#9474;   Motor    &#9474;  &#9474;
&#9474;  &#9474; Controller 1 &#9474; &#9474; Controller 2 &#9474; &#9474; Controller 3 &#9474; &#9474; Controller &#9474;  &#9474;
&#9474;  &#9474;     (FL)     &#9474; &#9474;     (FR)     &#9474; &#9474;     (RL)     &#9474; &#9474;   4 (RR)   &#9474;  &#9474;
&#9474;  &#9492;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9496; &#9492;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9496; &#9492;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9496; &#9492;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9496;  &#9474;
&#9492;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9496;
                                 &#9474;
                                 &#9660;
&#9484;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9488;
&#9474;                    STEEL CHASSIS (Base)                             &#9474;
&#9474;                                                                     &#9474;
&#9474;    &#9484;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9488;                              &#9484;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9488;         &#9474;
&#9474;    &#9474;  MECANUM  &#9474;                              &#9474;  MECANUM  &#9474;         &#9474;
&#9474;    &#9474;   WHEEL   &#9474;  &#9552;&#9552;&#9552;&#9552;&#9552;&#9552;&#9552;&#9552;&#9552;&#9552;&#9552;&#9552;&#9552;&#9552;&#9552;&#9552;&#9552;&#9552;&#9552;&#9552;&#9552;&#9552;&#9552;&#9552;    &#9474;   WHEEL   &#9474;         &#9474;
&#9474;    &#9474;   (FL)    &#9474;       25GA-370 Motor         &#9474;   (FR)    &#9474;         &#9474;
&#9474;    &#9492;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9496;                              &#9492;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9496;         &#9474;
&#9474;         &#9553;                                          &#9553;                &#9474;
&#9474;         &#9553;              STEEL FRAME                 &#9553;                &#9474;
&#9474;         &#9553;                                          &#9553;                &#9474;
&#9474;    &#9484;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9488;                              &#9484;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9488;         &#9474;
&#9474;    &#9474;  MECANUM  &#9474;                              &#9474;  MECANUM  &#9474;         &#9474;
&#9474;    &#9474;   WHEEL   &#9474;  &#9552;&#9552;&#9552;&#9552;&#9552;&#9552;&#9552;&#9552;&#9552;&#9552;&#9552;&#9552;&#9552;&#9552;&#9552;&#9552;&#9552;&#9552;&#9552;&#9552;&#9552;&#9552;&#9552;&#9552;    &#9474;   WHEEL   &#9474;         &#9474;
&#9474;    &#9474;   (RL)    &#9474;       25GA-370 Motor         &#9474;   (RR)    &#9474;         &#9474;
&#9474;    &#9492;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9496;                              &#9492;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9496;         &#9474;
&#9474;                                                                     &#9474;
&#9492;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9496;</code></pre><h2>Motor Layout and Encoder Wiring</h2><p>The motor arrangement follows the standard mecanum convention:</p><pre><code>M4 (FL) --- M1 (FR)
   |    X    |
M3 (RL) --- M2 (RR)</code></pre><p>Each motor's encoder connects to GPIO pins on the Jetson. I use BOARD pin numbering:</p><pre><code>Motor  Position     GPIO Pins (BOARD)
-----  -----------  -----------------
M1     Front-Right  7, 11
M2     Rear-Right   13, 15
M3     Rear-Left    29, 31
M4     Front-Left   32, 33</code></pre><p>The encoder reader uses interrupt-based counting to track wheel rotations. With only 2 PPR, the resolution is quite coarse, which is why sensor fusion (EKF) is essential for usable odometry.</p><h2>Encoder Calibration: The gear_ratio Challenge</h2><p>Getting accurate odometry from low-resolution encoders requires careful calibration. The <code>gear_ratio</code> parameter was the most critical value to tune.</p><p><strong>The Problem:</strong> With 2 PPR encoders, each encoder pulse represents a significant distance traveled. The gear ratio converts raw encoder counts into actual wheel rotations. An incorrect gear_ratio causes massive position drift.</p><p><strong>Calibration Process:</strong></p><ol><li><p>Start with an initial gear_ratio estimate (I started with 50.0)</p></li><li><p>Mark the robot's starting position with tape</p></li><li><p>Drive exactly 1.0 meter forward using a measuring tape</p></li><li><p>Check the reported odometry position</p></li><li><p>Calculate the correction factor</p></li></ol><p>When I tested with gear_ratio=50.0, driving 1.0m forward reported 4.483m. The correction:</p><pre><code>new_gear_ratio = 50.0 &#215; (4.483 / 1.0) = 224.0</code></pre><p>After setting gear_ratio=224.0, the robot reported 1.008m for 1.0m actual travel - an error of just 0.8%.</p><h2>Odometry Configuration</h2><p>The odometry parameters live in <code>config/odometry_params.yaml</code>:</p><pre><code>wheel_radius: 0.040     # 40mm measured
wheel_base: 0.095       # 95mm measured
track_width: 0.205      # 205mm measured
encoder_ppr: 2          # Hall sensors: 2 PPR
gear_ratio: 224.0       # Calibrated for 25GA-370 motors
publish_rate: 50.0      # Hz</code></pre><p>The covariance matrices are set high because the 2 PPR encoders are inherently noisy:</p><pre><code>pose_covariance_diagonal: [1.0, 1.0, 0.0, 0.0, 0.0, 1.0]
twist_covariance_diagonal: [0.5, 0.5, 0.0, 0.0, 0.0, 0.5]</code></pre><h2>EKF Sensor Fusion</h2><p>With only 2 PPR encoders, raw odometry is too noisy for SLAM. The <code>robot_localization</code> package implements an Extended Kalman Filter (EKF) that smooths the odometry data.</p><p>The data flow:</p><pre><code>Raw Encoders &#8594; odometry_node &#8594; /odom (noisy)
                                   &#8595;
                            ekf_filter_node (smoothing)
                                   &#8595;
                          /odometry/filtered (clean)
                                   &#8595;
                        SLAM &amp; Nav2 (accurate mapping/navigation)</code></pre><p>The EKF combines the noisy encoder measurements with a motion model, weighting each based on configured covariances. The result is much smoother position estimates that SLAM can actually use.</p><h2>Flask Teleoperation Server</h2><p>Before diving into autonomous navigation, I needed a way to manually control the robot and view the camera feed. The solution was a Flask-based web interface that runs on the Jetson.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!u6R3!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7d900d7a-17db-4bd3-8730-16ac193e7860_2169x1259.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!u6R3!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7d900d7a-17db-4bd3-8730-16ac193e7860_2169x1259.png 424w, https://substackcdn.com/image/fetch/$s_!u6R3!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7d900d7a-17db-4bd3-8730-16ac193e7860_2169x1259.png 848w, https://substackcdn.com/image/fetch/$s_!u6R3!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7d900d7a-17db-4bd3-8730-16ac193e7860_2169x1259.png 1272w, https://substackcdn.com/image/fetch/$s_!u6R3!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7d900d7a-17db-4bd3-8730-16ac193e7860_2169x1259.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!u6R3!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7d900d7a-17db-4bd3-8730-16ac193e7860_2169x1259.png" width="728" height="422.5689257722453" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/7d900d7a-17db-4bd3-8730-16ac193e7860_2169x1259.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:false,&quot;imageSize&quot;:&quot;normal&quot;,&quot;height&quot;:1259,&quot;width&quot;:2169,&quot;resizeWidth&quot;:728,&quot;bytes&quot;:2071782,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!u6R3!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7d900d7a-17db-4bd3-8730-16ac193e7860_2169x1259.png 424w, https://substackcdn.com/image/fetch/$s_!u6R3!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7d900d7a-17db-4bd3-8730-16ac193e7860_2169x1259.png 848w, https://substackcdn.com/image/fetch/$s_!u6R3!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7d900d7a-17db-4bd3-8730-16ac193e7860_2169x1259.png 1272w, https://substackcdn.com/image/fetch/$s_!u6R3!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7d900d7a-17db-4bd3-8730-16ac193e7860_2169x1259.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><h3>Why Web-Based Control?</h3><p>A web interface has significant advantages over traditional approaches:</p><ul><li><p><strong>Device agnostic</strong> - Control from any phone, tablet, or laptop on the network</p></li><li><p><strong>No installation</strong> - Just open a browser and navigate to the robot's IP</p></li><li><p><strong>Visual feedback</strong> - Live camera stream alongside controls</p></li><li><p><strong>Development friendly</strong> - Easy to extend with new features</p></li></ul><h3>Architecture</h3><p>The Flask server exposes two main endpoints:</p><p><strong>Camera Stream</strong> (<code>/video_feed</code>)</p><ul><li><p>MJPEG stream from the RealSense RGB camera</p></li><li><p>Runs at ~15 FPS to balance quality and bandwidth</p></li><li><p>Uses OpenCV to capture and encode frames</p></li></ul><p><strong>Motor Control</strong> (<code>/cmd_vel</code>)</p><ul><li><p>Accepts POST requests with velocity commands</p></li><li><p>Translates to ROS2 Twist messages</p></li><li><p>Implements dead zone for joystick drift</p></li></ul><h3>Web Joystick</h3><p>The frontend uses a virtual joystick library that maps touch/mouse input to velocity commands:</p><ul><li><p><strong>Vertical axis</strong> &#8594; Linear X velocity (forward/backward)</p></li><li><p><strong>Horizontal axis</strong> &#8594; Angular Z velocity (rotation)</p></li><li><p>Smooth ramp-up/down for natural feel</p></li></ul><h3>Running the Server</h3><pre><code>cd ~/ros2_ws/src/ogre-teleop
python3 app.py</code></pre><p>Then open a browser to <code>http://&lt;robot-ip&gt;:8080</code>. The interface shows the camera feed with an overlaid joystick control.</p><p>This web interface proved invaluable for testing motor calibration, verifying camera mounting angles, and generally getting a feel for how the robot handles before writing autonomous code.</p><h2>TF Frame Structure</h2><p>ROS2's TF system tracks the relationship between coordinate frames. For Project Ogre on the real robot:</p><pre><code>map (from slam_toolbox or amcl)
  &#9492;&#9472; odom (from EKF or odometry_node)
      &#9492;&#9472; base_link (robot center at wheel axle height)
          &#9500;&#9472; laser (RPLIDAR: 0.30m up, 180&#176; rotated)
          &#9492;&#9472; camera_link (RealSense D435: 0.15m forward, 0.10m up)</code></pre><p>Note that the LIDAR is mounted with a 180&#176; rotation. This is common - just ensure your URDF/launch files account for it.</p><h2>Power Considerations</h2><p>The entire robot runs from a single 5000mAh 6S LiPo battery (~24V nominal). Three buck converters distribute power to different subsystems:</p><ul><li><p><strong>24V &#8594; 12V</strong>: Powers the Jetson Orin Nano (accepts 9-20V input)</p></li><li><p><strong>24V &#8594; 9V</strong>: Powers the motor controllers</p></li><li><p><strong>24V &#8594; 5V</strong>: Powers the 5V rail for logic-level components</p></li></ul><p>This single-battery approach keeps the robot compact and simplifies charging. The 6S LiPo provides plenty of headroom for the Jetson's power demands while the buck converters ensure each subsystem receives clean, regulated voltage.</p><h3>Weight Distribution</h3><p>One challenge I didn't anticipate was weight balance. The 6S LiPo battery is heavy and sits at the rear of the robot, which created an uneven weight distribution. Without counterbalancing, too much torque to the motors would cause the robot to tip backward and fall on its back.</p><p>My solution was to 3D print small cylindrical barrels and fill them with fishing weights (lead sinkers). These counterweights mount at the front of the chassis to balance out the battery's mass. It works, but it's not elegant.</p><p>In hindsight, I would have tried harder to position the battery at the robot's center of mass from the start. Planning for weight distribution early in the design process would have saved me from adding dead weight to compensate later. Lesson learned for the next build.</p><h2>What's Next</h2><p>With the robot built and calibrated, <a href="https://protomota.com/blog/project-ogre-part2-isaac-sim">Part 2</a> covers building an accurate digital twin in NVIDIA Isaac Sim. Creating a simulation environment first would have been the smarter approach, but I was too eager to get hardware running. The digital twin lets us:</p><ul><li><p>Test code changes without risking hardware damage</p></li><li><p>Iterate faster with instant simulation resets</p></li><li><p>Develop navigation algorithms in a controlled environment</p></li><li><p>Debug issues that are hard to reproduce on real hardware</p></li></ul><p>The simulation uses the same ROS2 interface as the real robot, so the same code runs in both environments.</p>]]></content:encoded></item><item><title><![CDATA[Real-Time SO-ARM 101 ROS2 Teleoperation in Isaac Sim with LeRobot]]></title><description><![CDATA[Building a real-time teleoperation bridge between the SO-ARM 101 robotic arm and NVIDIA Isaac Sim using LeRobot and ROS2.]]></description><link>https://newsletter.protomota.com/p/real-time-so-arm-101-ros2-teleoperation</link><guid isPermaLink="false">https://newsletter.protomota.com/p/real-time-so-arm-101-ros2-teleoperation</guid><dc:creator><![CDATA[Brad Dunlap]]></dc:creator><pubDate>Sun, 31 May 2026 19:17:37 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/9c573bc3-b06b-4557-845f-b9d934c92ab0_1200x900.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>If you're interested in robotics and AI, you've probably heard of the <a href="https://www.youtube.com/watch?v=oitT8geMat0">SO-ARM 101</a>, the open-source robotic arm developed by Hugging Face in collaboration with The Robot Studio, and supported by a growing community of makers worldwide. Hugging Face's CEO has called it "the first robot arm any AI builder should buy," and it's quickly becoming one of the most popular platforms for learning imitation and reinforcement learning with real hardware. The best part? It's 3D printable, costs between $100 and $500 depending on how you source it, and integrates directly with the LeRobot framework.</p><blockquote><p><strong>Project Goal:</strong> Enable real-time control of the SO-ARM 101 digital twin in Isaac Sim using the physical leader arm.</p></blockquote><blockquote><p><strong>&#128230; Full Source Code:</strong> All updated code, ROS2 integration, and configuration files are publicly available in my forked GitHub repository at <a href="https://github.com/protomota/lerobot-sim">github.com/protomota/lerobot-sim</a>. My contributions are detailed in PROTOMOTA_README.md.</p></blockquote><p>In the video below, you'll see a split-screen view: on the left is the physical 3D-printed leader arm from the SO-ARM 101, and on the right is a screen recording of Isaac Sim showing the digital twin responding in real-time to my movements. I'm using the leader arm as a controller for the simulated robot, with every joint movement mirrored instantly in the simulation.</p><p>To get this working, I built a ROS2 bridge between the LeRobot teleoperation framework and Isaac Sim. I wrote a custom teleoperation controller that reads joint positions from the physical leader arm and publishes them as ROS2 messages. On the Isaac Sim side, there's an action graph that subscribes to those ROS2 commands and converts them into movements within the simulation, creating seamless real-time control.</p><p>&#9654; <a href="https://www.youtube.com/watch?v=KPL8jyaWyp8">Watch the video on YouTube</a></p><p>---</p><h2>The Challenge with Separate Processes</h2><p>I started with LycheeAI's approach, which uses separate processes for teleoperation and joint state publishing. It's a clean, logical design - separation of concerns at its finest. However, I ran into challenges getting it to work reliably with my hardware setup.</p><p>The core issue is that the Feetech STS3215 motors communicate over USB serial using half-duplex communication. When two processes try to access the same serial port simultaneously, you get packet collisions, corrupted data, and error messages like "There is no status packet" flooding your terminal.</p><p>I experimented with various solutions - mutex locks, reduced read frequencies, queue-based IPC - but couldn't achieve the reliability I needed for real-time teleoperation.</p><h2>The Integrated Solution</h2><p>The fix was elegant: integrate ROS2 publishing into the teleoperation loop itself. The loop already reads joint positions every cycle, so just publish them to ROS2 at the same time.</p><pre><code>&#9484;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9488;
&#9474;                   Teleoperation Loop                   &#9474;
&#9474;                                                        &#9474;
&#9474;   1. Read leader position (ACM0)                       &#9474;
&#9474;   2. Read follower position (ACM1)  &#9472;&#9472;&#9658; ROS2 Publish   &#9474;
&#9474;   3. Write to follower (ACM1)                          &#9474;
&#9474;                                                        &#9474;
&#9492;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9496;</code></pre><p>No port conflicts. 60Hz publishing. Smooth simulation.</p><p>The new command is called <code>lerobot-ros-teleoperate</code>, and it supports two modes:</p><p><strong>Full mode</strong> is when you have both the leader and follower arms connected. The leader controls the follower, and the follower's positions get published to ROS2.</p><p><strong>Sim-only mode</strong> is when you only have the leader arm. The leader positions get published directly to ROS2, driving the simulation without needing physical follower hardware.</p><p>This is huge for development. You can iterate on your simulation setup using just the leader arm, without needing the full dual-arm rig powered up.</p><h2>USB Connection Order Matters</h2><p>One gotcha that tripped me up: USB port assignment is based on plug-in order. The first arm you plug in gets <code>/dev/ttyACM0</code>, the second gets <code>/dev/ttyACM1</code>.</p><p>I standardized on:</p><ol><li><p>Leader first, gets ACM0</p></li><li><p>Follower second, gets ACM1</p></li></ol><p>If you get weird behavior where the wrong arm is responding, unplug both and reconnect in the correct order.</p><h2>Gripper Calibration</h2><p>The gripper needed special handling. Once I had basic teleoperation working, I noticed the gripper wasn't behaving correctly in Isaac Sim. It would move, but not through its full range, and the closed position wasn't lining up.</p><p>After some debugging, I discovered the issue: the physical gripper outputs values from about 0.02 radians when closed to 1.72 radians when open. But Isaac Sim's Jaw joint needed to reach -11 degrees to fully close.</p><p>The fix was two-part:</p><ol><li><p>Set Isaac Sim Jaw limits to -11 (lower) and 100 (upper)</p></li><li><p>Apply -0.21 rad offset in code</p></li></ol><p>Now the gripper tracks perfectly.</p><h2>Bridging to Isaac Sim</h2><p>The <code>lerobot-ros-teleoperate</code> command publishes joint positions to <code>/joint_states</code>, which is the standard ROS2 topic for joint data. But the Isaac Sim USD file from LycheeAI has its action graph configured to listen on <code>/isaac_joint_command</code>.</p><p>Rather than modify either side, I used <code>topic_tools relay</code>, a standard ROS2 utility that republishes messages from one topic to another. It's a one-liner that bridges the two:</p><pre><code>ros2 run topic_tools relay /joint_states /isaac_joint_command</code></pre><p>This keeps the LeRobot side using standard conventions while matching what Isaac Sim expects.</p><h2>Getting the Code</h2><blockquote><p><strong>IMPORTANT:</strong> To get this working, you need to clone my forked repository that includes the integrated ROS2 teleoperation solution and all the modifications discussed in this post.</p></blockquote><p>Clone the repository:</p><pre><code>git clone https://github.com/protomota/lerobot-sim.git
cd lerobot-sim</code></pre><p>Follow the installation instructions in the repository README to set up the environment and dependencies.</p><h2>Quick Start</h2><pre><code>source /opt/ros/humble/setup.bash
conda activate lerobot

# Terminal 1: Start teleoperation (leader arm on ACM0)
lerobot-ros-teleoperate \
    --teleop.type=so101_leader \
    --teleop.port=/dev/ttyACM0 \
    --teleop.id=armatron_leader

# Terminal 2: Relay joint states to Isaac Sim's expected topic
ros2 run topic_tools relay /joint_states /isaac_joint_command</code></pre><p>Then open Isaac Sim, load your USD file, and press Play. The simulated arm will mirror your physical leader arm in real-time.</p><h2>What's Next</h2><p>With teleoperation working, the next step is adding cameras. The goal is vision-based imitation learning, where the robot learns tasks by watching demonstrations.</p><p>The plan:</p><ul><li><p><strong>Add camera feeds</strong> to capture what the robot "sees" during teleoperation</p></li><li><p><strong>Record demonstrations</strong> pairing camera frames with joint positions as the operator performs tasks</p></li><li><p><strong>Train vision policies</strong> that map camera input directly to robot actions</p></li><li><p><strong>Deploy to real hardware</strong> using the same camera setup on the physical follower arm</p></li></ul><p>LeRobot already has infrastructure for this. The framework supports recording datasets with synchronized camera and joint data, and includes ACT (Action Chunking Transformer) and other imitation learning policies out of the box.</p><p>If you found this useful, let me know in the comments. And if you're building something similar, I'd love to hear about it.</p><h2>Credits</h2><ul><li><p><a href="https://github.com/TheRobotStudio/SO-ARM100">TheRobotStudio</a> - Original SO-ARM 100 design and hardware</p></li><li><p><a href="https://github.com/huggingface/lerobot">LeRobot</a> - Hugging Face's framework for real-world robotics</p></li><li><p><a href="https://lycheeai-hub.com/project-so-arm101-x-isaac-sim-x-isaac-lab-tutorial-series/so-arm-teleoperate-real-isaac-sim">LycheeAI</a> - Isaac Sim URDF modifications, Action Graph info, and initial ROS2 bridge concepts</p></li></ul>]]></content:encoded></item><item><title><![CDATA[Project NVIDIA JetBot (Orin Nano)]]></title><description><![CDATA[Reviving a cool little project.]]></description><link>https://newsletter.protomota.com/p/project-nvidia-jetbot-orin-nano</link><guid isPermaLink="false">https://newsletter.protomota.com/p/project-nvidia-jetbot-orin-nano</guid><dc:creator><![CDATA[Brad Dunlap]]></dc:creator><pubDate>Sun, 31 May 2026 18:45:57 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/583517f8-452b-443a-aa3b-1848e7e0c4e9_1201x901.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>About Project NVIDIA JetBot (Orin Nano)</h2><p><a href="https://jetbot.org">JetBot (jetbot.org)</a> is an educational robot platform originally designed for the now-discontinued Jetson Nano. While the newer NVIDIA Jetson Orin Nano Super Developer Kit (8GB) offers significantly more computing power, the official JetBot project hasn't been updated since February 2021. The existing documentation only provides an outdated parts list for the Orin Nano, with no software support outside of some other forked repos.</p><blockquote><p><strong>Project Goal:</strong> Fully adapt the JetBot platform to work with the newer, more powerful NVIDIA Jetson Orin Nano, including all necessary hardware modifications, software updates, and Jupyter notebook compatibility.</p></blockquote><blockquote><p><strong>&#128230; Full Source Code:</strong> All updated code, configuration files, and Docker containers are publicly available in my forked GitHub repository at <a href="https://github.com/protomota/jetbot-orin">github.com/protomota/jetbot-orin</a></p></blockquote><p>After extensive testing and code modifications, I successfully adapted JetBot to run on the Orin Nano, including all the original Jupyter notebooks. This post shares my implementation to help others build their JetBots with the newer hardware.</p><h2>JetBot in Action</h2><p>Here's the modified JetBot Orin Nano demonstrating autonomous navigation on a toy roadway using vision-based training and machine learning. The robot uses its camera to detect road boundaries and navigate the course in real-time, showcasing the practical capabilities of the Orin Nano's enhanced computing power for inference and decision-making.</p><p>&#9654; <a href="https://www.youtube.com/watch?v=gLUGdiM5JT8">Watch the video on YouTube</a></p><h2>The Power Challenge</h2><p>The biggest hurdle I encountered was the power requirements. The original Nano runs on 5V, while the Orin Nano requires 9-20V. Combined with the motor controller and motor requirements, standard 10,000mAh USB power banks can't deliver this higher voltage reliably. You can power one or the other, but not both. After testing numerous options (and making far too many returns to Amazon), I decided that the best and easiest solution would be dual power banks: one for the Orin Nano and one for the motors and controller. While this adds weight and complexity, the plus side is longer runtime. Until someone starts selling a custom Power Delivery Board desinged for the Orin Nano, this is the best solution I could come up with.</p><h2>Model Redesign</h2><p>This presented another problem - the original chassis model only fits one battery. So I needed to modify the battery compartment area of the model to support dual 10,000mAh batteries and then reprint it.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!H9sP!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F108e796c-7a82-44f7-8d78-dc924a21f2d3_1536x1024.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!H9sP!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F108e796c-7a82-44f7-8d78-dc924a21f2d3_1536x1024.png 424w, https://substackcdn.com/image/fetch/$s_!H9sP!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F108e796c-7a82-44f7-8d78-dc924a21f2d3_1536x1024.png 848w, https://substackcdn.com/image/fetch/$s_!H9sP!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F108e796c-7a82-44f7-8d78-dc924a21f2d3_1536x1024.png 1272w, https://substackcdn.com/image/fetch/$s_!H9sP!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F108e796c-7a82-44f7-8d78-dc924a21f2d3_1536x1024.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!H9sP!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F108e796c-7a82-44f7-8d78-dc924a21f2d3_1536x1024.png" width="728" height="485.3333333333333" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/108e796c-7a82-44f7-8d78-dc924a21f2d3_1536x1024.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:false,&quot;imageSize&quot;:&quot;normal&quot;,&quot;height&quot;:1024,&quot;width&quot;:1536,&quot;resizeWidth&quot;:728,&quot;bytes&quot;:837311,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!H9sP!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F108e796c-7a82-44f7-8d78-dc924a21f2d3_1536x1024.png 424w, https://substackcdn.com/image/fetch/$s_!H9sP!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F108e796c-7a82-44f7-8d78-dc924a21f2d3_1536x1024.png 848w, https://substackcdn.com/image/fetch/$s_!H9sP!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F108e796c-7a82-44f7-8d78-dc924a21f2d3_1536x1024.png 1272w, https://substackcdn.com/image/fetch/$s_!H9sP!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F108e796c-7a82-44f7-8d78-dc924a21f2d3_1536x1024.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p><strong>Download the modified chassis with printer settings:</strong> <a href="https://protomota.com/stls/chassis-dual.3mf">chassis-dual.3mf</a></p><p><strong>Download STL only:</strong> <a href="https://protomota.com/stls/chassis-dual.stl">chassis-dual.stl</a></p><h2>Updated Bill of Materials for JetBot Orin Nano</h2><p>While the JetBot community has updated the <a href="https://jetbot.org/master/bill_of_materials_orin.html">parts list for the Orin Nano</a>, the current recommendations are partially outdated and don't properly address the power requirements. While most of the instructions in the parts list are accurate and you will need the parts, the instructions will only get you 80% there. There are many broken links and outdated recommendations. Here are my recommended improvements:</p><h3>NVIDIA Jetson Orin Nano</h3><ul><li><p><strong>Developer Kit</strong>: <a href="https://a.co/d/25PFWsQ">NVIDIA Jetson Orin Nano Super Developer Kit (8GB)</a> - Our robot brain.</p></li></ul><h3>Power Solution</h3><ul><li><p><strong>Power Banks</strong>: 2 x <a href="https://a.co/d/7xAGgzn">35W 10,000mAh Power Bank</a> - You need two of these. One for the Orin Nano, and one for the motors and motor controller. The larger wattage on this model is also essential for meeting the Orin Nano's higher power demands. 35W will also allow you to run the Orin Nano at full 25W power. This particular model is available under a couple of different names as it is a white label product. This one is particularly cool as the power reading buttons and readout conveniently fit perfectly in the windows of the JetBot.</p></li><li><p><strong>Power Adapter</strong>: <a href="https://a.co/d/dkdyue0">USB-C to DC Adapter</a> - Much cleaner and more compact solution without cable management headaches of the recommended 6-foot cable.</p></li><li><p><strong>USB-C Cable</strong>: <a href="https://a.co/d/4MnorER">Short USB-C Cable</a> - This plugs into the battery and the power adapter.</p></li></ul><h3>Storage Upgrade</h3><p>Instead of an SD card, I strongly recommend upgrading to an <a href="https://a.co/d/eRiWrBd">NVME M.2 SSD</a>. This provides significantly better performance and reliability.</p><p><strong>Important</strong>: Installing Jetpack on the Orin Nano with an SSD requires a host x86 PC running Ubuntu for the reflashing process. ARM-based systems like Raspberry Pi won't work for this.</p><p>If you don't have an x86 Ubuntu machine, you can purchase an <a href="https://a.co/d/1Lq3vmM">affordable mini PC</a> for about the same cost as a Raspberry Pi. Install Ubuntu alongside or instead of Windows for the setup process.</p><p>I had the idea that it might work in an Ubuntu virtual machine, but I don't know if that is supported. Something to try if you need to.</p><h2>Getting Started</h2><p>Follow the <a href="https://docs.nvidia.com/jetson/jetpack/install-setup/index.html">NVIDIA Jetpack SDK Installation Instructions</a> to install the latest Jetpack on your Jetson Orin Nano.</p><h3>Hardware Setup and Motor Controller Configuration</h3><p>Before running the software, ensure your hardware is properly configured:</p><h3>Motor Controller Power</h3><p>The Adafruit MotorHAT/FeatherWing motor controller requires <strong>external power</strong> (6-12V) separate from the Jetson.</p><p><strong>Important</strong>: Check that the motor controller has a <strong>green power LED lit at all times</strong>. Some power banks automatically shut down if they detect low current draw, thinking nothing is connected. If you're using a power bank:</p><ul><li><p>Use a power bank that supports low-current devices</p></li><li><p>Monitor the green LED - if it turns off, your power source has shut down</p></li><li><p>NVIDIA does not yet have an official recommendation or solution for powering the Orin Nano from a dedicated battery pack designed for robotics. It is possible, but outside the scope of this tutorial. Venture at your own risk as it has its own set of technical challenges.</p></li></ul><h3>Connecting to your NVIDIA Orin Nano</h3><p>Open your terminal and SSH into your Orin Nano:</p><pre><code>ssh jetbot@&lt;jetbot_ip&gt;</code></pre><h3>I2C Bus Configuration</h3><p>Since the Orin Nano I2C bus is different (7, rather than the default 1), you need to set the <code>JETBOT_I2C_BUS</code> environment variable:</p><pre><code># Add to ~/.bashrc for persistence
echo 'export JETBOT_I2C_BUS=7' &gt;&gt; ~/.bashrc
source ~/.bashrc</code></pre><h3>Installing the JetBot Python Package</h3><p>Create a directory called 'source' in your home directory:</p><pre><code>cd ~
mkdir source</code></pre><p><strong>IMPORTANT</strong>: Clone my forked and updated repository that works with the Orin Nano hardware:</p><pre><code>cd source
git clone https://github.com/protomota/jetbot-orin.git</code></pre><p>Install the JetBot Python package:</p><pre><code>cd jetbot-orin
python3 setup.py install --user</code></pre><h3>Configure System</h3><p>First, run the <code>scripts/configure_jetson.sh</code> script to configure the power mode and other parameters.</p><pre><code>cd ~/source/jetbot-orin
./scripts/configure_jetson.sh</code></pre><h3>Configure Docker Environment</h3><p>Navigate to the docker directory and source the <code>configure.sh</code> script to configure environment variables.</p><pre><code>cd docker
source configure.sh</code></pre><h3>Build Docker Containers</h3><p>Build all JetBot containers from scratch:</p><pre><code>./build.sh</code></pre><p>This step is required before running the containers for the first time.</p><h3>Enable and Start Containers</h3><p>Enable Docker to start at boot and launch the JetBot containers:</p><pre><code>./enable.sh $HOME</code></pre><p>The directory you specify (e.g., <code>$HOME</code>) will be mounted as <code>/workspace</code> in the Jupyter container. All work saved in <code>/workspace</code> will persist across container restarts.</p><h3>Access Jupyter Lab</h3><p>Open your web browser and navigate to:</p><pre><code>https://&lt;jetbot_ip&gt;:8888</code></pre><p>The default password is <code>jetbot</code>.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!1RhE!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8e7912b2-a682-4068-b84f-26b2c9151b0b_1536x1024.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!1RhE!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8e7912b2-a682-4068-b84f-26b2c9151b0b_1536x1024.png 424w, https://substackcdn.com/image/fetch/$s_!1RhE!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8e7912b2-a682-4068-b84f-26b2c9151b0b_1536x1024.png 848w, https://substackcdn.com/image/fetch/$s_!1RhE!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8e7912b2-a682-4068-b84f-26b2c9151b0b_1536x1024.png 1272w, https://substackcdn.com/image/fetch/$s_!1RhE!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8e7912b2-a682-4068-b84f-26b2c9151b0b_1536x1024.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!1RhE!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8e7912b2-a682-4068-b84f-26b2c9151b0b_1536x1024.png" width="728" height="485.3333333333333" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/8e7912b2-a682-4068-b84f-26b2c9151b0b_1536x1024.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:false,&quot;imageSize&quot;:&quot;normal&quot;,&quot;height&quot;:1024,&quot;width&quot;:1536,&quot;resizeWidth&quot;:728,&quot;bytes&quot;:70302,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!1RhE!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8e7912b2-a682-4068-b84f-26b2c9151b0b_1536x1024.png 424w, https://substackcdn.com/image/fetch/$s_!1RhE!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8e7912b2-a682-4068-b84f-26b2c9151b0b_1536x1024.png 848w, https://substackcdn.com/image/fetch/$s_!1RhE!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8e7912b2-a682-4068-b84f-26b2c9151b0b_1536x1024.png 1272w, https://substackcdn.com/image/fetch/$s_!1RhE!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8e7912b2-a682-4068-b84f-26b2c9151b0b_1536x1024.png 1456w" sizes="100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><h2>Optional Configuration</h2><h3>Enable JetBot Python API Support</h3><p>Run setup (optional, but recommended - only needed if you plan to use JetBot Python APIs outside of Docker):</p><pre><code>cd ~/source/jetbot-orin
python3 setup.py install --user</code></pre><h3>Power Mode (Jetson Orin Nano 8GB)</h3><p>For <strong>Jetson Orin Nano 8GB Developer Kit</strong>, we recommend lowering the power mode to 3W for optimal battery operation:</p><p>Common Orin Nano power modes:</p><ul><li><p>Mode 0: MAXN (15W/20W depending on your module)</p></li><li><p>Mode 1: 10W mode (power-saving)</p></li><li><p>Mode 2: 15W mode</p></li><li><p>Mode 3: 7W mode</p></li></ul><pre><code>sudo nvpmodel -m 3</code></pre><p>Then verify it worked:</p><pre><code>sudo nvpmodel -q</code></pre><h2>Managing Containers</h2><h3>Stop All Containers</h3><pre><code>cd docker
./disable.sh</code></pre><h3>Rebuild Containers</h3><pre><code>cd docker
./disable.sh
./build.sh
./enable.sh $HOME</code></pre><h2>Important Notes</h2><ul><li><p>The containers will restart automatically on boot</p></li><li><p>Work saved outside of <code>/workspace</code> in the Jupyter container will be lost when the container restarts</p></li><li><p>The NVIDIA runtime is automatically configured by <code>configure.sh</code></p></li><li><p>Docker daemon is automatically enabled at boot by <code>configure.sh</code></p></li></ul>]]></content:encoded></item><item><title><![CDATA[Fewer People, Less Process: How AI Changes the Way Teams Ship]]></title><description><![CDATA[Linear says issue tracking is dead. They are half right.]]></description><link>https://newsletter.protomota.com/p/fewer-people-less-process-how-ai</link><guid isPermaLink="false">https://newsletter.protomota.com/p/fewer-people-less-process-how-ai</guid><dc:creator><![CDATA[Brad Dunlap]]></dc:creator><pubDate>Sun, 12 Apr 2026 16:31:12 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/ad96ddee-b02f-4eef-90bf-ce149cfabb5a_1376x768.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Linear just declared <a href="https://linear.app/next">issue tracking dead</a>. Their CEO Karri Saarinen: "Complexity started to look like sophistication. Overhead kept growing, and the process became the work." They launched an AI agent that generates issues, triages automatically, and will soon write code. 75% of their enterprise workspaces already have coding agents installed. Agents now author nearly 25% of new issues.</p><p>Their pitch is "context over process." I think Saarinen is right about the diagnosis. But he's solving the wrong layer of the problem.</p><h2>Process became the product a long time ago</h2><p>If you've used Jira or Azure DevOps at a company with more than 50 engineers, you know what he's talking about. Jira's the one everyone loves to hate, but Azure DevOps is the quiet offender. Same disease in a Microsoft suit: work items linked to test plans linked to boards nobody opens, welded to your CI/CD so the process overhead becomes literally impossible to remove without breaking your builds.</p><p>Theo Browne <a href="https://www.youtube.com/watch?v=example">talked about this</a> &#8212; his Jira at Twitch took over two minutes to load. That's not a performance bug. That's what happens when every stakeholder gets to add a field.</p><p>I've lived both sides of this. For a stretch of my career I was a Jira admin at a 100-person company. It consumed about half my day. Not building. Not shipping. Tweaking workflows, fixing broken automations, cleaning up schemes that had drifted into nonsense. Half my working hours maintaining a tool whose purpose was supposed to be helping other people work. I also spent time as a product owner on a large enterprise team, basically living in Azure DevOps. Grooming backlogs, writing acceptance criteria, sitting in sprint ceremonies, managing boards. The work was real, but it wasn't building. It was managing the process around building.</p><p>At my core I'm a builder. I went back to building. Those process management roles no longer provide value in the ways we need to work today. The future belongs to builders, not process managers.</p><p>The Agile Manifesto said "working software over comprehensive documentation" in 2001. Then an industry of consultants, certification bodies, and framework vendors built a multi-billion-dollar empire around the ceremony of being agile. SAFe. Scrum certifications. "Agile transformations" led by people who haven't shipped code in a decade. Microsoft built an entire product around it. Atlassian built an entire company around it. The tools designed to make teams agile became the heaviest things in the building.</p><p>The <a href="https://theagilemindset.substack.com/p/agile-is-dead-long-live-agile">agile industrial complex</a> is real. The principles were never the problem. The industry that grew around them was.</p><h2>What AI actually changes</h2><p>Theo's approach at Twitch was to skip the spec and build a rough prototype to discover requirements. Before AI, that worked if you had the right engineer. But in the AI era, I think you actually need the spec more, not less. Not the 20-page Google Doc. A markdown file produced by the subject matter experts. What you're building, who it's for, what done looks like. Enough context to hand an agent and get a useful first pass.</p><p>You can't "skip the spec" when your builder is an LLM. The prompt IS the spec. The question is whether you write a thoughtful one that gets a working prototype on the first pass, or wing it and spend three hours course-correcting.</p><p>Write the spec, prompt the agent, get a prototype, refine. That's the loop. The cost of a first pass is approaching zero. When it costs an afternoon and a good prompt, you iterate faster than any sprint cycle could keep up with.</p><h2>A new type of team is emerging</h2><p>A solo founder with AI agents can ship what used to take 15 engineers. A team of three can operate at the scale of thirty. The leverage is compounding fast, and it changes what tools you need.</p><p>Jira and Azure DevOps exist because large teams need coordination overhead. Tickets, workflows, sprint boards, estimation rituals &#8212; all coordination tax. The cost of having a lot of humans in the same codebase. But the teams forming now don't look like that. They don't need sprint boards because there's no sprint. They don't need estimation because one person holds the whole context.</p><p>Smaller doesn't mean solo, though. There are unicorns who can design, code, manage product, and test. They exist. But they're rare, and even the best of them usually can't match the quality a small team of specialists produces. The unicorn gets you to market. The team gets you to quality.</p><p>The roles haven't gone away. You still need a designer's eye, an engineer's instinct for what breaks at scale, a PM who manages the project and works with clients, a QA engineer who thinks adversarially. What changes is headcount and throughput. A designer with AI explores ten directions in the time it used to take to mock up two. An engineer ships in a day what used to take a sprint. AI is a productivity accelerator, not a role eliminator. A team of five with the right specializations can operate like fifty used to.</p><p>That's the market Linear is actually chasing. Not better issue tracking for big teams. Lighter tools for smaller teams that punch above their weight.</p><h2>The business model is breaking too</h2><p>This isn't just a team structure problem. It's a pricing problem.</p><p>Software projects used to be measured in developer hours. Entire sales organizations were built around scoping engagements at X hours times Y rate. That math is collapsing. The honest unit of work is shifting from hours to tokens, and while nobody has a clean way to estimate token cost for a project yet, the direction is obvious: it's a lot less. That's all that matters.</p><p>Software can't be sold at the prices it was even two years ago. A project that a consultancy would have quoted at six months and half a million dollars can now be built by a small team in weeks for a fraction of the cost. The buyers are figuring this out. The sellers, a lot of them, haven't.</p><p>Large firms that only chase big deals are going to get their lunch taken by smaller indie dev shops that move faster, charge less, and ship better work with leaner teams. The overhead that justified premium pricing, the project managers, the Jira admins, the sprint ceremonies, the 30-person delivery teams, none of that scales the way it used to. It's just cost now.</p><p>Offshoring is facing the same math. The whole model was built on labor cost arbitrage: cheaper hourly rates in other markets. But when the unit of work shifts from hours to tokens, that gap shrinks fast. Tokens cost the same no matter where the developer sits. Meanwhile, the coordination costs haven't changed. Time zone gaps, communication overhead, context getting lost across languages and cultures, losing direct control over the taste and style of the output, the quality variance that comes from managing work at a distance. Those tradeoffs used to be worth it because the savings were significant. When a small local team with AI tools can compete on price, the economic case for offshoring gets a lot harder to make.</p><p>Sales teams are struggling and a lot of them don't understand why. The pipeline looks the same. The pitch decks look the same. But the deals aren't closing because the buyers can see the math changing. The teams that adapt, that price for the new reality and sell speed and quality instead of headcount, will win. The ones still quoting based on 2023 economics are going to have a rough year.</p><h2>What AI doesn't change</h2><p>You still don't know what your users want. <a href="https://www.inflectra.com/Ideas/Whitepaper/Is-Agile-Dead.aspx">Inflectra put it well</a>: "If code shows up faster than requirements learning, you can ship the wrong thing even sooner."</p><p>AI makes shipping faster. It does not make learning faster. Skip the feedback loops and you just automate failure. The Agile principle of "optimize for learning" matters more now than it did in 2001. Short feedback loops aren't optional when your agent can ship a feature before lunch.</p><p>What actually dies is the handoff model. Not the roles, but the assembly line where each role produces an artifact for the next to consume. When your team is small enough that everyone shares context directly, you don't need the proxy. The teams that win will be the ones with taste &#8212; who know what's worth building and can spot "this is wrong" before users have to tell them.</p><p>When building is cheap, judgment becomes the bottleneck.</p><h2>The manifesto was right</h2><p>Issue tracking isn't dead. Bad issue tracking deserved to die. The bloated Jira instances, the Azure DevOps boards nobody opens, the velocity charts that measured activity instead of outcomes.</p><p>What survives is what the Agile Manifesto said 25 years ago. Build working software. Collaborate with your users. Respond to change. Value people over process. We buried those principles under dashboards and certifications and two-week rituals. AI is digging them back out.</p><p>The future isn't post-Agile. It's Agile without the industry that grew around it.</p><p>Build something. Learn from it. Do it again.</p>]]></content:encoded></item><item><title><![CDATA[bringing it back home: gemma 4 on a 60-watt box]]></title><description><![CDATA[I put Gemma 4 on a Jetson AGX Orin. It's better than it should be.]]></description><link>https://newsletter.protomota.com/p/bringing-it-back-home-gemma-4-on</link><guid isPermaLink="false">https://newsletter.protomota.com/p/bringing-it-back-home-gemma-4-on</guid><dc:creator><![CDATA[Brad Dunlap]]></dc:creator><pubDate>Fri, 10 Apr 2026 20:52:25 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/adf0785c-952f-40a8-9259-052936c1ec37_1376x768.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>A few weeks ago I wrote about getting Qwen 3.5 35B-A3B running on my Jetson AGX Orin through vLLM and OpenClaw. It worked. It was fast enough. I was happy with it.</p><p>But every time I looked at my setup there was a small mental asterisk: I was running my local agent stack on weights trained by Alibaba. Not because I had any concern about the model, but because I'm an American indie builder and my favorite local model was coming out of Hangzhou. It felt slightly off-brand for what I'm trying to build.</p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://newsletter.protomota.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Protomota Lab is a reader-supported publication. To receive new posts and support my work, consider becoming a free or paid subscriber.</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div><p>Gemma 4 26B-A4B is the first genuinely capable small US open weights model I've been able to run on my own hardware. Not "capable for a small model." Capable, full stop.</p><h2>Why 26B on an Orin should not work</h2><p>The AGX Orin is a 275mm x 87mm module that draws about 60 watts under load. 64GB of unified memory shared between CPU and GPU. Ampere GPU, 2048 CUDA cores. It sits on my workbench next to my dev machine.</p><p>Gemma 4 26B-A4B is not a normal 26B model. It has 25.2 billion total parameters, but only 3.8 billion are active during any given inference pass. 128 experts per MoE layer, router picks 2 per token, the rest sit idle. The file is 16.8GB at Q4_K_M, but the compute per token is closer to a 4B model.</p><p>That's the whole trick. Training quality of a 26B, inference cost of a 4B. On hardware where every watt matters, that's the difference between "runs" and "doesn't."</p><p>NVIDIA's Jetson AI Lab lists the AGX Orin as a supported platform and ships a Docker container with llama.cpp pre-configured:</p><pre><code>sudo docker run -it --rm --pull always --runtime=nvidia --network host \
  -v $HOME/.cache/huggingface:/root/.cache/huggingface \
  ghcr.io/nvidia-ai-iot/llama_cpp:gemma4-jetson-orin \
  llama-server -hf ggml-org/gemma-4-26B-A4B-it-GGUF:Q4_K_M</code></pre><p>One line. Model takes ~24GB of RAM. On my 64GB Orin, that leaves plenty of room for the rest of the system.</p><h2>What "runs" actually means</h2><p>Set expectations. At Q4 on the Orin, Gemma 4 26B-A4B is not fast. I'm getting around 11 tokens per second, which I'd call "comfortable for non-interactive use." Background tasks, batch processing, offline analysis. Not real-time chat.</p><p>For my use cases this is fine. I'm not building a chatbot on the Orin. I'm running background agent tasks: log analysis, code review, config generation, structured extraction. None of those need sub-second latency. They need good output on short-to-medium prompts.</p><p>And the output is where 26B earns its place. Sub-10B models on this hardware work for simple stuff (summarize this, extract these fields, classify this log line), but they fall apart on anything that needs real reasoning. A 3B model asked to analyze a stack trace gives you something plausible that's wrong half the time. A 7B does better but hallucinates function names. Gemma 4 26B is a different animal. It understands code, handles tool calling, follows multi-step instructions without losing the thread.</p><p>The 256K context window doesn't hurt either. I can feed it a whole config file, a stack trace, and the relevant source all at once.</p><p>For the first time, I have a model on the Orin that I trust to do work I'd previously have to ship to an API.</p><h2>Rough edges</h2><p><strong>Skip Ollama on Jetson.</strong> There's an open bug where <code>gemma4:26b</code> throws HTTP 500s on moderately long context on the Orin specifically. Looks like a memory management issue with Ollama's CUDA integration on Jetson. Stick with llama.cpp.</p><p><strong>Long context is slow.</strong> The 256K window exists but filling it is painful. Prefill scales with context length, and the Orin's GPU takes a while on 50K+ tokens. I keep my prompts under 10K for anything that needs to respond in under a minute.</p><p><strong>Multimodal is experimental.</strong> Vision through llama.cpp on Jetson isn't mature yet. The text capabilities are solid.</p><h2>The punchline</h2><p>I've been waiting for a model smart enough to be useful and small enough to run on hardware I actually own. Not a rented H100. Not a cloud API I'm paying per token for. Not a Mac Studio I'd have to buy specifically for this. A 60-watt box I already have on my desk.</p><p>Gemma 4 26B-A4B is the first model that meets both criteria on the Orin.</p><p>It's not going to replace Claude or GPT for complex, multi-turn reasoning. It's not going to write this newsletter. But for the work that needs to happen locally, without a cloud round-trip, it is the best option available today. And it's US open weights, which means I can finally stop the mental asterisk.</p><p>The interesting question isn't whether it's as good as a cloud model. It obviously isn't. The interesting question is whether it's good enough that you stop needing the cloud model for a meaningful chunk of your workload.</p><p>On my bench, the answer is yes. For the first time.</p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://newsletter.protomota.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Protomota Lab is a reader-supported publication. To receive new posts and support my work, consider becoming a free or paid subscriber.</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div>]]></content:encoded></item><item><title><![CDATA["Just ship it" is bad advice for solo founders]]></title><description><![CDATA[The decision framework I actually use before pressing deploy]]></description><link>https://newsletter.protomota.com/p/just-ship-it-is-bad-advice-for-solo</link><guid isPermaLink="false">https://newsletter.protomota.com/p/just-ship-it-is-bad-advice-for-solo</guid><dc:creator><![CDATA[Brad Dunlap]]></dc:creator><pubDate>Thu, 02 Apr 2026 17:25:01 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/2d401c14-dace-4b55-a3cc-2f71ec5f6cb4_1376x768.jpeg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>"Just ship it" sounds brave. It sounds like the kind of thing someone with a Y Combinator hoodie says while sipping cold brew in a WeWork. And for a team of six with a safety net of funding and a product manager who can course-correct after launch, it's fine advice.</p><p>For a solo founder, it's a trap.</p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://newsletter.protomota.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Protomota Lab is a reader-supported publication. To receive new posts and support my work, consider becoming a free or paid subscriber.</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div><p>When you ship something broken and you're the only person who can fix it, you don't get to "learn from the market." You get buried in support emails, refund requests, and a reputation hit you can't delegate to anyone. There's no PM absorbing the fallout while you iterate. There's just you, triaging at midnight.</p><p>Shipping too early as a solo founder is worse than shipping too late, because the recovery tax falls entirely on one person. You can't parallelize damage control and feature work when you're the whole company.</p><h2>The framework</h2><p>Five questions before anything goes live. Not a checklist in a project management tool. Just five things worth thinking through before you hit deploy.</p><p><strong>1. Can a new user finish the core loop without help?</strong></p><p>Not "can they figure it out if they're technical." Can a regular person sign up, do the main thing, and get value without messaging you? If the answer is no, it's not ready. Doesn't matter how clever the architecture is.</p><p>That dead end on one screen size, the missing empty state, the confusing button label. Nobody complains on day one. By day seven you're answering the same question in every support thread.</p><p><strong>2. What breaks if twice as many people use it as you expect?</strong></p><p>Solo founders don't have an SRE team. If your database melts or your API rate limits get hit on launch day, you're the one SSHing in while your phone blows up. Spend 30 minutes thinking about what happens at 2x your optimistic traffic estimate. Usually the answer is "nothing, because my traffic estimates are delusional." But sometimes it catches a real problem: a missing index, a webhook that retries infinitely on failure.</p><p><strong>3. Is there a way to undo this if it goes wrong?</strong></p><p>Feature flags. Database backups verified within the last 24 hours. A rollback path that doesn't require rewriting migrations at 2 AM. If you can't reverse the deploy in under 10 minutes, maybe don't ship it on a Friday.</p><p>The trap here: migrations that are technically reversible but practically aren't, because the rollback would wipe user data created after the deploy. Test your rollback, don't just confirm it exists.</p><p><strong>4. Are you shipping this because it's ready, or because you're tired of looking at it?</strong></p><p>There's a specific kind of fatigue that hits around week three of working on a feature. You start telling yourself "good enough" when you mean "I'm sick of this." Those are not the same thing.</p><p>If you're excited to announce it, it's probably ready. If you're relieved to be done with it, you're probably trying to escape the project, not finish it.</p><p><strong>5. What's the support burden for the first 48 hours?</strong></p><p>Can you actually be available? Or are you shipping right before going offline for six hours? Solo founders don't get to ship and disappear. The three people who try it and hit a bug during that window will leave and never come back.</p><p>Block your calendar for 48 hours after any significant release. No deep work on other projects. Just monitoring, responding, and fixing whatever surfaces. Boring, but it's the difference between a launch and a mess.</p><h2>When "just ship it" actually works</h2><p>Some things genuinely benefit from speed over polish: internal tools only you use, blog posts, landing page copy, config changes. Anything where the blast radius is you and only you.</p><p>There's also a middle ground: give it away free during a beta period. People who aren't paying have a completely different tolerance for rough edges. They'll tell you what's broken instead of demanding a refund. They'll stick around through the awkward phase because they feel like they're part of building something, not customers who got shortchanged.</p><p>A free beta buys you the one thing solo founders don't have: room to be wrong. You still need to care about the experience, but the stakes on any single release drop significantly when nobody's credit card is attached.</p><p>If a stranger will interact with it, run the five questions. If only you will, ship it. If it's a free beta, you can be a little more aggressive &#8212; but don't skip question one. A broken core loop wastes everyone's time, paying or not.</p><h2>The real problem with "just ship it"</h2><p>The advice assumes you have buffer. Someone to catch what you miss.</p><p>Solo founders have none of that. Every hour spent recovering from a bad launch is an hour not spent building the next thing. The math is brutal when there's only one of you.</p><p>Five questions, maybe 15 minutes total. Worth it every time.</p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://newsletter.protomota.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Protomota Lab is a reader-supported publication. To receive new posts and support my work, consider becoming a free or paid subscriber.</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div>]]></content:encoded></item><item><title><![CDATA[Stop trying to build a platform. Build a tool.]]></title><description><![CDATA[Platforms need ecosystems. Tools need one user with one problem.]]></description><link>https://newsletter.protomota.com/p/stop-trying-to-build-a-platform-build</link><guid isPermaLink="false">https://newsletter.protomota.com/p/stop-trying-to-build-a-platform-build</guid><dc:creator><![CDATA[Brad Dunlap]]></dc:creator><pubDate>Fri, 20 Mar 2026 19:42:08 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/34a84190-8e93-4f93-8021-6569c480b8e6_1264x848.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Every indie founder has the same inflection point. The product works for a handful of people. Usage is growing. And then the thought arrives: "What if we opened this up? What if other people could build on top of it? What if we became the platform?"</p><p>That thought has killed more promising products than bad code, bad marketing, and bad timing combined.</p><h2>The platform trap</h2><p>A tool solves a problem. A platform hosts other people's solutions to other people's problems. The difference in engineering effort, go-to-market strategy, and operational complexity is not 2x. It's 10x or more.</p><p>When Shopify was a tool, it helped merchants set up online stores. When it became a platform, it needed an app ecosystem, a developer relations team, an API stability guarantee, a review process for third-party apps, documentation for external developers, a partner program, and a fraud detection system for apps that abuse merchant data. Each of those is a company-sized problem on its own.</p><p>Shopify pulled it off because they had hundreds of millions in revenue and thousands of employees when they made that transition. They didn't start as a platform. They earned the right to become one.</p><p>Most indie products try to skip directly to platform. They add plugin systems before they have 100 users. They build APIs before anyone has asked for one. They design extensibility frameworks before the core product is stable. Then they spend six months maintaining infrastructure that nobody uses while the core experience rots.</p><h2>Tools have gravity. Platforms have overhead.</h2><p>A good tool attracts users because it solves their problem right now. No ecosystem required. No third-party developer community needed. Someone finds it, tries it, and either it works or it doesn't. The feedback loop is fast.</p><p>A platform attracts users only when the ecosystem around it has enough value to justify the switching cost. That's a chicken-and-egg problem that burns time and money. You need developers to build apps before users will adopt the platform, but developers won't build apps until there are users. Solving this requires either subsidizing developers (expensive) or building the first wave of apps yourself (which means you're back to building tools anyway).</p><p>The tool path: build something, ship it, get users, charge money. Six weeks to first revenue if the product is good.</p><p>The platform path: build something, build the developer tools around it, write documentation, recruit developers, hope they build things, hope users find those things. Twelve to eighteen months before the ecosystem generates value, if it ever does.</p><p>For a solo founder or small team, the platform path is almost always wrong. Not because platforms aren't valuable. Because you can't afford the timeline.</p><h2>The premature API</h2><p>The most common form of platform thinking in early-stage products: building an API before anyone has asked for one.</p><p>APIs are expensive to maintain. Once external developers depend on your API, every endpoint becomes a contract. Changing a response format breaks someone's integration. Deprecating a field requires a migration path. Version management becomes a permanent line item in your engineering budget.</p><p>If you have 50 users and zero of them have asked for API access, you don't need an API. You need a better core product. The time spent building and documenting an API could have gone toward fixing the three things your actual users complain about.</p><p>Build the API when someone emails you saying "I need to integrate this with my system and I'll pay more for it." That's demand. Everything before that is speculation.</p><h2>The plugin system nobody uses</h2><p>Same pattern. A product with 200 users adds a plugin architecture. Now the codebase has a plugin loader, a sandboxing layer, a configuration schema, and documentation for plugin developers. Total plugins written by external developers: zero.</p><p>The plugin system exists because the founder imagined an ecosystem. Not because users asked for extensibility. The fantasy is compelling: other people building features for your product, for free, that attract more users. In practice, plugin ecosystems only work at scale. WordPress has plugins because it has millions of sites. Figma has plugins because it has millions of designers. Your product with 200 users does not have the gravity to attract plugin developers.</p><p>Build the ten features your users actually need instead of building a system for other people to maybe build features someday.</p><h2>When to actually become a platform</h2><p>There are real signals that a product should add platform capabilities:</p><p>Users are building workarounds. They're scraping your UI, exporting CSVs and transforming them in scripts, or building unofficial integrations. This means the core product has value but doesn't connect to their workflow. An API makes sense here because the demand already exists.</p><p>Power users are asking for customization that would fracture the core product. If adding every custom request would turn the product into an unmanageable mess, a plugin or extension system lets power users solve their own edge cases without bloating the main product.</p><p>Revenue supports the investment. Platform infrastructure is ongoing cost. API maintenance, developer support, documentation updates, backwards compatibility testing. If the business can't absorb that cost for 12+ months with no direct revenue from the platform layer, it's too early.</p><p>A third party offers to build on top of your product and pay for the privilege. This is the clearest signal. Someone with money wants to extend your product for their own commercial purposes. That's real demand.</p><p>Everything else is founder fantasy about what the product could become if it were ten times bigger. Build for what it is now.</p><h2>The tool-first path</h2><p>Build a tool. Make it good. Charge for it. Get to the point where the tool generates enough revenue and has enough users that platform capabilities become a natural extension rather than a premature bet.</p><p>Basecamp was a project management tool for years before it became anything resembling a platform. Notion was a note-taking tool before it had an API. Linear was an issue tracker before it had integrations. Each of them nailed the core experience first. The platform layer came later, funded by tool revenue, pulled by user demand.</p><p>There's a version of your product that solves one problem really well for a specific group of people. That version is shippable in weeks, not months. It doesn't need an app store or a developer portal or an API reference. It needs to work, reliably, for the people who have that specific problem.</p><p>Ship that. Everything else is a distraction dressed up as ambition.</p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://newsletter.protomota.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Protomota Lab is a reader-supported publication. To receive new posts and support my work, consider becoming a free or paid subscriber.</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div>]]></content:encoded></item><item><title><![CDATA[Building a product nobody asked for]]></title><description><![CDATA[The market research industrial complex is killing more startups than bad ideas.]]></description><link>https://newsletter.protomota.com/p/building-a-product-nobody-asked-for</link><guid isPermaLink="false">https://newsletter.protomota.com/p/building-a-product-nobody-asked-for</guid><dc:creator><![CDATA[Brad Dunlap]]></dc:creator><pubDate>Fri, 13 Mar 2026 14:21:33 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/0592ca1e-c7dc-4b97-882a-d382884c550f_1264x848.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Two products I'm building right now started the same way. One generates and schedules content across multiple brands. The other packages AI agent capabilities into shareable, sellable units. Nobody asked for either. There was no survey. No customer discovery call. Just problems I kept running into and solutions I started building.</p><p>Neither has gone through a traditional validation process. They might fail. But they exist because I needed them, and that matters more than most founders think.</p><h2>The validation trap</h2><p>The standard startup advice goes like this: talk to 50 potential customers, identify a pain point, validate willingness to pay, build an MVP, test it with early adopters, iterate based on feedback.</p><p>This process is not wrong. It's just slow, expensive, and biased toward problems that are easy to articulate. The best products often solve problems people don't know how to describe yet. Nobody was asking for "a marketplace for AI agent skills" in 2024 because the concept barely existed. You can't validate demand for a category that hasn't been named.</p><p>The validation loop also selects for crowded markets. If 50 people can describe the problem clearly, 10 other teams are already building a solution. You've validated yourself into a competition.</p><h2>What works instead</h2><p>Build for yourself first. Not as a growth hack or a marketing angle. As a filter.</p><p>If you're building something you actually use, three things happen automatically:</p><p>You know the problem is real because you have it. Not "I think users might want this" but "I need this right now and nothing else does it." That's a different level of conviction. It survives the first bad week.</p><p>You're your own first tester. Every edge case, every friction point, every moment where the product falls short hits you before it hits anyone else. The feedback loop is instant. No surveys, no analytics dashboards, no waiting for support tickets. You just feel it.</p><p>You build faster because you skip the specification phase. When you're building for yourself, you already know the requirements. The PRD is in your head because you lived it. The "what should this do?" question has an obvious answer: whatever you need it to do right now.</p><h2>The market research industrial complex</h2><p>There's a whole ecosystem that profits from making founders feel like building is the risky part. Market research firms, customer discovery consultants, survey tools, validation frameworks. They all sell the same premise: the biggest risk is building the wrong thing, and their process reduces that risk.</p><p>The premise isn't entirely false. Building the wrong thing does happen. But for solo founders and small teams, the bigger risk is building nothing at all. Analysis paralysis kills more projects than bad product-market fit. You can pivot a launched product. You can't pivot a spreadsheet full of interview notes.</p><p>A founder who ships something ugly in two weeks and gets 5 real users has more useful information than a founder who spent three months interviewing 50 people and hasn't written a line of code.</p><h2>When validation actually matters</h2><p>There are situations where building without validation is genuinely reckless:</p><p>Enterprise software where each sale takes 6 months and costs $200K to close. You need to know the buyer exists and has budget before you invest a year of engineering.</p><p>Hardware products where the manufacturing minimum is 10,000 units and $500K. The cost of being wrong is bankruptcy, not wasted weekend hours.</p><p>Products that require regulatory approval. If you need FDA clearance or financial licensing, validate first because the compliance timeline is measured in years.</p><p>For software products built by a small team with low overhead? Just build it. The cost of being wrong is a few weeks of work. The cost of over-validating is months of delay while someone else ships the thing you were researching.</p><h2>There are no new ideas</h2><p>The other thing founders get wrong: chasing originality. Spending months looking for an idea nobody's had before. That's backwards.</p><p>If nobody has built it, there's usually a reason. Either the problem doesn't matter enough, the timing is wrong, or someone tried and failed for reasons you haven't discovered yet. Searching for a completely novel idea is one of the slowest ways to start a company.</p><p>The better approach: find something that already exists, that people already pay for, and build a version that's better for a specific group. Content scheduling tools exist. Dozens of them. But none of them were built for someone running five brands from one desk with AI agents handling the drafting. That's a niche. That's a differentiator. The category is validated. The specific angle is not.</p><p>Stripe didn't invent online payments. PayPal existed. Stripe built a payments API for developers instead of for merchants. Same category, different angle, better product for a specific audience.</p><p>Notion didn't invent note-taking or project management. They combined them in a way that attracted a specific kind of user who wanted flexibility over structure.</p><p>Linear didn't invent issue tracking. They built a version of it that was fast when everything else was slow, and opinionated when everything else tried to be configurable.</p><p>Competition is validation. If other companies are making money in the space, the demand is real. Your job is not to find a market with zero competitors. Your job is to find the gap in an existing market where nobody is serving a specific need well.</p><p>Look at what's already working. Find the group of users that existing products are ignoring or underserving. Build for them. That's faster, cheaper, and more likely to work than chasing a completely original idea.</p><h2>The unfair advantage of building what you need</h2><p>Products built for yourself have a quality that's hard to replicate: they're opinionated. They make decisions about how things should work instead of trying to accommodate every possible use case.</p><p>Basecamp was built because 37signals needed project management for their own client work. Stripe was built because the Collison brothers needed a payment API that didn't make them want to throw their laptop out a window. Slack was a chat tool built inside a gaming company that realized the chat was more valuable than the game.</p><p>None of these started with "let's research the project management / payments / enterprise chat market." They started with "this sucks, let me build something better." The product reflected specific, strong opinions about how work should happen. Those opinions attracted users who agreed and repelled users who didn't. That's not a bug. That's positioning.</p><p>Generic products built from surveys and focus groups tend toward the middle. They satisfy the average case and delight nobody. Products built from frustration tend toward extremes. They nail one workflow perfectly and ignore everything else. Users who have that exact workflow become devoted fans. Everyone else uses something else, and that's fine.</p><h2>The practical version</h2><p>If you're sitting on an idea and debating whether to validate or build:</p><p>Ask one question: do you personally have the problem this solves? If yes, build it this week. Use whatever tools get you to a working version fastest. Don't worry about scale, don't worry about the tech stack, don't worry about what happens at 10,000 users. You don't have 10,000 users. You have one: yourself.</p><p>Use your own product for two weeks. Write down every time it frustrates you, every time it's missing something, every time you work around a limitation. Fix the worst one. Repeat.</p><p>After a month of daily use, show it to three people who have the same problem. Not 50. Three. If two of them say "can I use this?" you have something. If all three shrug, you learned that in a month instead of three months of interviews.</p><p>The validation happened. It just happened through building and usage instead of through research and surveys. The information is better because it came from real behavior, not hypothetical answers to hypothetical questions.</p><h2>The thing nobody tells you about market research</h2><p>Survey responses are aspirational. Interview answers are performative. People describe the version of themselves they want to be, not the version that actually opens their laptop at 9 AM.</p><p>"Would you pay $20/month for a tool that does X?" gets a 70% yes rate in a survey. Actual conversion when the tool launches is closer to 3%. The gap between stated intent and real behavior is enormous, and no amount of careful survey design closes it.</p><p>Usage data is the only honest signal. And you can only get usage data from something that exists. Which means you have to build it first.</p><p>The entire market validation industry exists because of a reasonable fear (building the wrong thing) applied to the wrong solution (asking people what they want instead of watching what they do). Asking is cheap and feels productive. Building is expensive and feels risky. But building gives you the only data that actually predicts whether the product works.</p><p>Build the thing. Use the thing. Fix the thing. Show three people the thing.</p><p>That's the whole process.</p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://newsletter.protomota.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Protomota Lab is a reader-supported publication. To receive new posts and support my work, consider becoming a free or paid subscriber.</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div>]]></content:encoded></item><item><title><![CDATA[Memory is the hard part]]></title><description><![CDATA[Most agent setups forget everything between sessions. Here's what a working memory system actually looks like.]]></description><link>https://newsletter.protomota.com/p/memory-is-the-hard-part</link><guid isPermaLink="false">https://newsletter.protomota.com/p/memory-is-the-hard-part</guid><dc:creator><![CDATA[Brad Dunlap]]></dc:creator><pubDate>Tue, 10 Mar 2026 15:45:17 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/2b75b68c-58a8-401e-959c-7feb598ca356_1264x848.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>If you're running AI agents for anything beyond one-shot tasks, you've probably noticed the same thing: they forget everything between sessions.</p><p>The LinkedIn agent doesn't know what it posted yesterday. The security agent doesn't remember which servers it already patched. The publishing agent has no idea which drafts are staged and which were rejected. Every session starts from zero. Every session wastes its first few minutes rediscovering context that should have been obvious.</p><p>Memory is what turns a chatbot into something useful. Almost nobody builds it right.</p><h2>What memory actually means in an agent system</h2><p>When people say "memory" in the context of AI agents, they usually mean one of three things, and they're usually conflating all three.</p><p><strong>Conversation history</strong> is what ChatGPT gives you. The model remembers what you said earlier in the thread. Cheapest form of memory, least useful for real work. It dies when the session ends. It fills the context window. It can't be shared across agents.</p><p><strong>Retrieved context</strong> is RAG. Embed documents, store them in a vector database, retrieve relevant chunks at query time. Works for knowledge bases. Terrible for operational memory because relevance scoring doesn't understand time, recency, or task state. A vector search for "what's the current deploy status" might return a doc from three months ago because the words match.</p><p><strong>Persistent state</strong> is what agents actually need. Not "what documents are similar to this query" but "what happened yesterday, what's in progress right now, and what decisions were already made." Most setups skip this entirely.</p><p>My system uses all three, but the third one does the heavy lifting.</p><h2>The MEMORY.md pattern</h2><p>The pattern that works: give every agent a MEMORY.md file. Plain markdown. Human-readable. Version-controlled by nature because it lives in a git repo.</p><p>A LinkedIn agent's MEMORY.md tracks voice guidelines, which posts performed well, which topics to avoid, and the current posting schedule. A security agent's MEMORY.md tracks which servers have been audited, what vulnerabilities were found, and which patches are pending.</p><p>Not a database. A text file the agent reads at session start and updates when something important changes. The format is just markdown:</p><pre><code># MEMORY.md - Agent Name

## Role
What this agent does.

## Current State
What's in progress right now.

## Decisions Made
Things that were decided and shouldn't be relitigated.

## Preferences
How the human wants things done.</code></pre><p>Why markdown instead of a database?</p><p><strong>Debuggability.</strong> When an agent does something wrong, open the MEMORY.md and read it. You can see exactly what the agent knew. No query logs, no embedding inspection, no "why did the retrieval return that chunk?" detective work. Just text.</p><p><strong>Editability.</strong> Open the file and change it. If an agent has a wrong assumption baked into memory, fix it in thirty seconds. Try doing that with a vector store.</p><p><strong>Portability.</strong> Every agent's memory is a text file in a git repo. You can grep across all agent memories in one command. Diff changes over time. Copy one agent's memory pattern to bootstrap a new agent.</p><h2>Semantic search on top of flat files</h2><p>MEMORY.md handles persistent state. But agents also need to search their memory when a question comes up that isn't covered by the top-level file.</p><p>Agents can also have a `memory/` directory with dated entries and topic-specific files. When an agent gets a question about something from last Tuesday, it runs a semantic search across MEMORY.md and everything in `memory/`. The search uses embeddings (text-embedding-3-small works fine) and returns the top snippets with file paths and line numbers.</p><p>The flow: agent gets a question, runs `memory_search`, gets back relevant snippets with citations, pulls the specific lines it needs with `memory_get`, and answers with full context.</p><p>This is where the "retrieved context" layer comes in, but it's searching the agent's own operational history, not a generic document corpus. A RAG system pointed at your company wiki retrieves information. This retrieves experience.</p><h2>Why RAG alone fails for operational memory</h2><p>A common mistake: building agent memory with RAG and nothing else. Embed all the docs, all the Slack messages, all the meeting notes into a vector store and point the agent at it.</p><p>It works for answering questions about static knowledge. "What's our refund policy?" gets the right answer because the refund policy doc is sitting in the index and the embedding similarity is high.</p><p>It fails for anything time-sensitive or state-dependent. "What did we decide about the pricing change?" might return four different documents from four different meetings because they all discuss pricing. The agent has no way to know which one is current. "What's the status of the deployment?" returns nothing useful because deployment status isn't a document, it's a state that changes every hour.</p><p>The fix isn't better embeddings or fancier retrieval. The fix is a separate memory layer that tracks state explicitly. MEMORY.md handles "what is true right now." Semantic search handles "what happened before that might be relevant." RAG handles "what do we know about this topic in general." Three layers, three purposes.</p><h2>The memory lifecycle</h2><p>Memory isn't write-once. It has a lifecycle.</p><p><strong>Capture</strong>: Something important happens during a session. The agent writes it to memory. Not everything, just decisions, outcomes, and state changes. An agent that logs every API call to memory will drown in noise. An agent that logs "deployed v2.3 to production, all tests passing, monitoring for 24h" gives its future self exactly what it needs.</p><p><strong>Recall</strong>: At the start of every session, the agent loads its MEMORY.md. This is automatic in my system. The agent's workspace files (SOUL.md, MEMORY.md, TOOLS.md) are injected into every session. For deeper recall, the agent runs semantic search when a question requires historical context.</p><p><strong>Decay</strong>: Old memory needs to age out or get compressed. A MEMORY.md that grows forever becomes useless. I handle this with periodic consolidation: the agent reviews its memory, keeps what's still relevant, archives what isn't, and summarizes patterns. This happens on a schedule, not continuously.</p><p><strong>Correction</strong>: Sometimes memory is wrong. The agent believed something that turned out to be false, or a decision was reversed. The human edits the MEMORY.md directly. This is why plain text matters. Correcting a vector embedding is a research project. Correcting a markdown file is a text edit.</p><h2>What breaks when memory is wrong</h2><p>Bad memory is worse than no memory.</p><p>An agent with no memory starts fresh every session. It's slow but safe. It asks questions it's asked before. It redoes work it's done before. Annoying but not dangerous.</p><p>An agent with wrong memory acts on false beliefs with full confidence. The security agent that "remembers" a server was patched when it wasn't. The publishing agent that "remembers" a draft was approved when it was actually rejected. The financial agent that "remembers" an invoice was sent when it's still in queue.</p><p>This is why MEMORY.md being human-readable matters. Review agent memories periodically. Not every day, but often enough to catch drift. When an agent starts making decisions that don't make sense, check its memory first. Nine times out of ten, something in the MEMORY.md is stale or wrong.</p><h2>The cost math</h2><p>Memory isn't free. Every MEMORY.md that gets loaded into a session consumes tokens. Semantic search costs embedding API calls. Storing files costs disk space (trivial) and git history (also trivial).</p><p>The real cost is context window usage. A 500-line MEMORY.md takes roughly 2,000 tokens. Across a fleet of agents running multiple sessions per day, that adds up. But prompt caching covers most of it. MEMORY.md files load at session start, which means they hit the cache on subsequent messages within the same session. First load costs full price. Everything after costs roughly 10% because the cached prefix matches.</p><p>Without memory, agents waste tokens rediscovering context. An agent that spends 500 tokens re-asking "what's the current status?" every session burns more than the 2,000-token memory file that would have answered it upfront. The net cost of memory is negative.</p><h2>Getting started</h2><p>If you're running agents and haven't built a memory layer, start here:</p><ol><li><p>Create a MEMORY.md for your most important agent. Put the basics in it: what the agent does, what's currently in progress, and any decisions that shouldn't be repeated.</p></li></ol><ol><li><p>Tell the agent to update its MEMORY.md when something important changes. Not after every message. After outcomes: task completed, decision made, error encountered.</p></li></ol><ol><li><p>After a week, read the MEMORY.md. Is it useful? Does it contain things the agent actually needs to know? Trim the noise, keep the signal.</p></li></ol><ol><li><p>Add semantic search when you outgrow a single file. This usually happens when the agent has been running for a month and the memory directory has enough entries to make search worthwhile.</p></li></ol><ol><li><p>Set a reminder to review agent memories monthly. Catch stale beliefs before they cause problems.</p></li></ol><p>The whole system is plain text files with a search layer on top. No specialized infrastructure. No vector database to manage (the embeddings are computed at query time or cached locally). No migration path to worry about because markdown doesn't have schema changes.</p><p>Model quality matters less than you think. Memory quality matters more than anyone talks about.</p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://newsletter.protomota.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Protomota Lab is a reader-supported publication. To receive new posts and support my work, consider becoming a free or paid subscriber.</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div><p></p>]]></content:encoded></item><item><title><![CDATA[Running Qwen 3.5 35B on an NVIDIA Jetson AGX Orin with OpenClaw]]></title><description><![CDATA[A real-world tutorial &#8212; every command included]]></description><link>https://newsletter.protomota.com/p/running-qwen-35-35b-on-a-jetson-agx</link><guid isPermaLink="false">https://newsletter.protomota.com/p/running-qwen-35-35b-on-a-jetson-agx</guid><dc:creator><![CDATA[Brad Dunlap]]></dc:creator><pubDate>Sat, 07 Mar 2026 14:02:12 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/6af672d9-3246-4281-89dd-dd872ceb8e55_2816x1536.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<div><hr></div><h2>What I Built</h2><p>A NVIDIA Jetson AGX Orin 64GB running Qwen 3.5 35B-A3B (MoE, custom quantized) as a local AI model provider, fully integrated into an OpenClaw agent stack. My Mac calls the Jetson over LAN using a simple alias (<code>agx</code>) and gets 35B-level reasoning back at ~30 tok/sec &#8212; $0/month, 60 watts.</p><div><hr></div><h2>Hardware &amp; Specs</h2><ul><li><p><strong>Device:</strong> NVIDIA Jetson AGX Orin 64GB</p></li><li><p><strong>OS:</strong> Ubuntu, JetPack R36.4.7 (aarch64)</p></li><li><p><strong>CUDA:</strong> 12.6</p></li><li><p><strong>RAM:</strong> 64GB unified memory (CPU + GPU share it)</p></li><li><p><strong>Storage:</strong> 3.7TB NVMe</p></li><li><p><strong>Power:</strong> ~60W under load</p></li><li><p><strong>Model:</strong> <code>Kbenkhaled/Qwen3.5-35B-A3B-quantized.w4a16</code> (w4a16 quantized, vLLM)</p></li></ul><h3>Why MoE matters</h3><p>Qwen 3.5 35B-A3B is a <strong>Mixture of Experts</strong> model. 35B total parameters, but only ~3B active per token at inference. That's ~10x less memory bandwidth per inference compared to a dense model of the same size. The dense 27B Qwen variant is <em>slower</em> on the same hardware. MoE wins on edge hardware every time.</p><div><hr></div><h2>What NOT to Do: The Ollama Trap</h2><p>The standard Ollama build of Qwen does <strong>not</strong> optimize for Orin's CUDA architecture the same way. If you want real performance out of the Jetson, skip Ollama and use a custom quantized build served via <strong>vLLM</strong> with CUDA acceleration.</p><p>NVIDIA's Jetson AI Lab documents this model officially &#8212; that's where I found it: &#128073; <a href="https://www.jetson-ai-lab.com/models/qwen3-5-35b-a3b/">jetson-ai-lab.com/models/qwen3-5-35b-a3b</a></p><p>The specific quantized build I used is the <code>w4a16</code> variant optimized for Orin's architecture.</p><p>vLLM serves it with an OpenAI-compatible API on port 8000.</p><div><hr></div><h2>Step 1: Verify the Model is Running</h2><p>SSH into your Jetson and confirm vLLM is serving:</p><pre><code># Check what's listening on port 8000
ss -tlnp | grep 8000
# Should show: LISTEN 0 2048 0.0.0.0:8000 0.0.0.0:*

# Verify the model API is responding
curl -s http://localhost:8000/v1/models | python3 -m json.tool</code></pre><p>Test a completion:</p><pre><code>curl -s http://127.0.0.1:8000/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model": "Kbenkhaled/Qwen3.5-35B-A3B-quantized.w4a16",
    "messages": [{"role": "user", "content": "Say hello in one sentence."}],
    "max_tokens": 100
  }' | python3 -c "import json,sys; r=json.load(sys.stdin); print(r['choices'][0]['message']['content'])"</code></pre><div><hr></div><h2>Step 2: Configure OpenClaw on the Jetson</h2><p>Check what OpenClaw sees:</p><pre><code>openclaw models list</code></pre><p>Set the local model as the default:</p><pre><code>openclaw config set agents.defaults.model qwen
openclaw gateway restart</code></pre><blockquote><p><strong>Gotcha:</strong> <code>openclaw config set model qwen</code> doesn't work &#8212; <code>model</code> is not a root-level key. The correct path is <code>agents.defaults.model</code>.</p></blockquote><div><hr></div><h2>Step 3: Clean Up Stale Model Entries</h2><p>If you have leftover model entries (e.g., from an old Ollama provider or a stale alias), remove them:</p><pre><code># View current models config
openclaw config get models

# Remove stale provider (e.g. ollama pointing at port 11434 that isn't running)
python3 -c "
import json
with open('/home/agx/.openclaw/openclaw.json') as f:
    cfg = json.load(f)
cfg['models']['providers'].pop('ollama', None)
with open('/home/agx/.openclaw/openclaw.json', 'w') as f:
    json.dump(cfg, f, indent=2)
print('Done')
"

# Remove stale model alias from agents config
python3 -c "
import json
with open('/home/agx/.openclaw/openclaw.json') as f:
    cfg = json.load(f)
cfg['agents']['defaults']['models'].pop('kbenkhaled/Qwen3.5-35B-A3B-quantized.w4a16', None)
with open('/home/agx/.openclaw/openclaw.json', 'w') as f:
    json.dump(cfg, f, indent=2)
print('Done')
"

openclaw gateway restart</code></pre><blockquote><p><strong>Gotcha:</strong> There's no <code>openclaw models remove</code> command. You have to edit the JSON directly.</p></blockquote><blockquote><p><strong>Note on Ollama errors:</strong> OpenClaw has built-in Ollama auto-discovery that tries port 11434 at startup. If Ollama isn't running, you'll see <code>Failed to discover Ollama models: TypeError: fetch failed</code> in logs. This is cosmetic &#8212; it doesn't affect functionality. There's no config key to disable it in 2026.3.2.</p></blockquote><div><hr></div><h2>Step 4: Allow Remote Exec on the Jetson Node</h2><p>By default, agx requires approval for every <code>system.run</code> command from a remote session. To allow your main machine to run commands freely:</p><pre><code># Set security to full (no restrictions &#8212; fine for a trusted local node)
openclaw config set tools.exec.ask off
openclaw config set tools.exec.security full
openclaw gateway restart</code></pre><blockquote><p><strong>Gotcha:</strong> <code>security=allowlist</code> without defined safeBins will give you <code>allowlist miss</code> errors. Use <code>full</code> for a local trusted node, or define your safeBins list explicitly.</p></blockquote><div><hr></div><h2>Step 5: Add the Jetson as a Provider on Your Main Machine</h2><p>First, confirm your Mac can reach the Jetson's model server:</p><pre><code># Run this on your Mac
curl -s http://YOUR_JETSON_IP:8000/v1/models | python3 -m json.tool | head -10</code></pre><p>Then add it as a custom provider in OpenClaw (run on your Mac, or use the gateway tool):</p><pre><code>openclaw config set models.providers.agx-qwen '{
  "baseUrl": "http://YOUR_JETSON_IP:8000/v1",
  "apiKey": "none",
  "api": "openai-completions",
  "models": [{
    "id": "Kbenkhaled/Qwen3.5-35B-A3B-quantized.w4a16",
    "name": "AGX Qwen3.5-35B (local)",
    "input": ["text"],
    "contextWindow": 16000,
    "maxTokens": 4096
  }]
}'</code></pre><p>Replace <code>YOUR_JETSON_IP</code> with your Jetson's actual LAN IP:</p><pre><code># Check Jetson IP
hostname -I | awk '{print $1}'</code></pre><div><hr></div><h2>Step 6: Add a Model Alias</h2><pre><code># Run on your Mac
openclaw models aliases add agx agx-qwen/Kbenkhaled/Qwen3.5-35B-A3B-quantized.w4a16</code></pre><p>Now you can reference it anywhere as <code>agx</code>.</p><div><hr></div><h2>Step 7: Test It from Your Mac</h2><pre><code>curl -s http://YOUR_JETSON_IP:8000/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model": "Kbenkhaled/Qwen3.5-35B-A3B-quantized.w4a16",
    "messages": [{"role": "user", "content": "Write a one-paragraph story about a robot."}],
    "max_tokens": 300
  }' | python3 -c "import json,sys; r=json.load(sys.stdin); print(r['choices'][0]['message']['content'])"</code></pre><div><hr></div><h2>GPU Health Check</h2><pre><code># Real-time stats (run on Jetson)
tegrastats

# Or one-shot summary
nvidia-smi --query-gpu=name,temperature.gpu,utilization.gpu,memory.used,memory.total,power.draw --format=csv,noheader,nounits

# System overview
free -h          # RAM
df -h /          # Disk
uptime           # Load average</code></pre><p>Healthy idle numbers on the AGX 64GB running Qwen 3.5 35B-A3B:</p><ul><li><p>RAM: ~56GB used (model loaded in unified memory)</p></li><li><p>GPU temp: ~46&#176;C</p></li><li><p>GPU utilization: ~10% idle, spikes during inference</p></li><li><p>Power draw: ~3.5W idle</p></li></ul><div><hr></div><h2>Gotchas Summary</h2><ul><li><p>Issue: <code>Unrecognized key: "model"</code></p><p>Cause: Wrong config path</p><p>Fix: Use <code>agents.defaults.model</code> not <code>model</code></p></li></ul><ul><li><p>Issue: <code>SYSTEM_RUN_DENIED: approval required</code></p><p>Cause: Default node security</p><p>Fix: Run <code>openclaw config set tools.exec.security full</code> on the node</p></li></ul><ul><li><p>Issue: <code>SYSTEM_RUN_DENIED: allowlist miss</code></p><p>Cause: <code>security=allowlist</code> with no bins defined</p><p>Fix: Switch to <code>full</code> or define <code>safeBins</code></p></li></ul><ul><li><p>Issue: <code>openclaw models remove</code> not found</p><p>Cause: Command doesn't exist</p><p>Fix: Edit openclaw.json directly with python3</p></li></ul><ul><li><p>Issue: Ollama errors at startup</p><p>Cause: Built-in discovery, can't disable</p><p>Fix: Ignore &#8212; cosmetic only</p></li></ul><ul><li><p>Issue: Model output includes thinking chain</p><p>Cause: Reasoning mode baked into model</p><p>Fix: Add system prompt telling it to skip thinking, or disable reasoning in vLLM config</p></li></ul><div><hr></div><h2>Hardware Note: Orin Nano Super (8GB)</h2><p>Same approach works on an Orin Nano Super (8GB) &#8212; just use a smaller model. The Qwen 3.5 Small series (just released March 2026, 0.8B&#8211;9B range) is built for on-device/edge and fits the 8GB form factor. Methods are identical.</p><div><hr></div><h2>Why Bother?</h2><ul><li><p>$0/month operating cost</p></li><li><p>No API latency, no rate limits, no data leaving your network</p></li><li><p>60W power draw &#8212; runs all night on overnight tasks</p></li><li><p>35B-level reasoning for background jobs: research, batch processing, coding runs</p></li><li><p>Full tool-use and thinking capabilities</p></li><li><p>Still might become the brain for a robot someday</p></li></ul><div><hr></div><p><em>Originally set up March 6, 2026. All commands verified in production.</em></p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://newsletter.protomota.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">This Substack is reader-supported. To receive new posts and support my work, consider becoming a free or paid subscriber.</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div><p></p>]]></content:encoded></item><item><title><![CDATA[Why your AI workflow keeps skipping steps]]></title><description><![CDATA[The failure mode nobody warns you about, and how to design around it.]]></description><link>https://newsletter.protomota.com/p/why-your-ai-workflow-keeps-skipping</link><guid isPermaLink="false">https://newsletter.protomota.com/p/why-your-ai-workflow-keeps-skipping</guid><dc:creator><![CDATA[Brad Dunlap]]></dc:creator><pubDate>Fri, 06 Mar 2026 22:40:48 GMT</pubDate><enclosure url="https://substack-post-media.s3.amazonaws.com/public/images/611facb3-e64f-4b16-a0b7-80879ea0a6a4_2816x1536.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>If you've built a workflow with an AI agent, you've hit this: the agent does most of the task, declares victory, and stops. Step 6 never happened. The output looks plausible enough that you almost miss it.</p><p>This isn't a bug. It's how language models work. Understanding it changed how I build everything.</p><h2>Two kinds of workflows</h2><p>Deterministic workflows (n8n, Zapier, custom code) follow the same path every run. Step 2 fires after Step 1. If it breaks, it breaks the same way, which means you can find it and fix it. The limitation: you specify everything upfront. Every edge case. Every branch.</p><p>AI skill workflows don't have a fixed path. The model reads instructions, reasons about the task, and decides what to do. It handles ambiguity and adapts to context you never anticipated.</p><p>The tradeoff: execution quality depends on the model doing the work. Same skill file. Different model. Different results.</p><h2>How AI workflows actually fail</h2><p>Traditional software fails with wrong logic. You find it, you fix it.</p><p>AI workflows fail by <strong>premature completion</strong>. The model decides the task is "done enough" and wraps up. No error. No warning. It just stops.</p><p>This happens because language models are trained to be helpful and responsive. Finishing feels like the right move. The longer and more complex the task, the more likely the model cuts a corner, especially on steps that feel administrative or repetitive.</p><p>I see this constantly. My daily content pipeline has 7 steps. A weaker model hits Step 4, feels like the main work is done, and summarizes. A stronger model follows through to Step 7 even when it's grinding through the fifth piece of content in a row.</p><h2>Design for compliance, not just clarity</h2><p>Tightening the skill file helps more than switching models (though model choice matters too).</p><p><strong>Be explicit about completion.</strong> "DO NOT skip this step" and "This step is MANDATORY" aren't redundant. They counteract the model's natural tendency to treat later steps as optional.</p><p><strong>State completion criteria.</strong> Instead of "write social posts," write "write social posts for all four platforms: Twitter, Instagram, TikTok, YouTube Shorts. All four must be present before this step is complete."</p><p><strong>Use memory for standing rules.</strong> If a step gets skipped repeatedly, add it to memory with the date it was corrected. Models read prior corrections as high-priority context.</p><p><strong>Verify outputs, not just completion.</strong> Don't check whether the model said it finished. Check whether the output files exist, the word counts are right, the required sections are present.</p><h2>The hybrid architecture</h2><p>The most practical solution for complex pipelines: use deterministic tools for orchestration and AI for content.</p><p>n8n handles the skeleton. Trigger at 8am, pass Step 1 output to Step 2, wait for approval gate, continue. The structure is reliable. AI fills in the variable parts: writing the summary, picking the angle, adapting tone.</p><p>The mental model: n8n is the project manager, AI is the writer. The project manager doesn't forget steps. The writer doesn't need to think about pipeline logic.</p><h2>What to actually do</h2><p>If you're running AI workflows today:</p><ol><li><p><strong>Audit your skill files for vague step language.</strong> "Write social content" is not a complete instruction.</p></li><li><p><strong>Add explicit completion checks.</strong> List every required output, not just the task.</p></li><li><p><strong>Test with a weaker model.</strong> If a cheaper model skips steps, your skill isn't tight enough. Fix the skill, not the model tier.</p></li><li><p><strong>Consider hybrid architecture</strong> for any pipeline longer than 4-5 steps. The complexity cost of n8n pays off fast.</p></li></ol><p>The goal isn't to make AI workflows as reliable as traditional software. They can't be. The goal is to design them so the model's flexibility fills the right gaps, and none of the mandatory ones.</p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://newsletter.protomota.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">This Substack is reader-supported. To receive new posts and support my work, consider becoming a free or paid subscriber.</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div>]]></content:encoded></item><item><title><![CDATA[What a 3-person team that writes zero code is telling us]]></title><description><![CDATA[StrongDM built production security software with no human writing or reviewing a single line. Here's what they actually did &#8212; and what it means.]]></description><link>https://newsletter.protomota.com/p/what-a-3-person-team-that-writes</link><guid isPermaLink="false">https://newsletter.protomota.com/p/what-a-3-person-team-that-writes</guid><dc:creator><![CDATA[Brad Dunlap]]></dc:creator><pubDate>Fri, 06 Mar 2026 21:24:33 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!LUTR!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd13b495f-1318-406c-bcca-c1ca07304fab_3168x1344.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<div class="captioned-image-container"><figure><a class="image-link image2" target="_blank" href="https://substackcdn.com/image/fetch/$s_!LUTR!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd13b495f-1318-406c-bcca-c1ca07304fab_3168x1344.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!LUTR!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd13b495f-1318-406c-bcca-c1ca07304fab_3168x1344.png 424w, https://substackcdn.com/image/fetch/$s_!LUTR!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd13b495f-1318-406c-bcca-c1ca07304fab_3168x1344.png 848w, https://substackcdn.com/image/fetch/$s_!LUTR!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd13b495f-1318-406c-bcca-c1ca07304fab_3168x1344.png 1272w, https://substackcdn.com/image/fetch/$s_!LUTR!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd13b495f-1318-406c-bcca-c1ca07304fab_3168x1344.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!LUTR!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd13b495f-1318-406c-bcca-c1ca07304fab_3168x1344.png" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/d13b495f-1318-406c-bcca-c1ca07304fab_3168x1344.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:null,&quot;width&quot;:null,&quot;resizeWidth&quot;:728,&quot;bytes&quot;:null,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!LUTR!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd13b495f-1318-406c-bcca-c1ca07304fab_3168x1344.png 424w, https://substackcdn.com/image/fetch/$s_!LUTR!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd13b495f-1318-406c-bcca-c1ca07304fab_3168x1344.png 848w, https://substackcdn.com/image/fetch/$s_!LUTR!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd13b495f-1318-406c-bcca-c1ca07304fab_3168x1344.png 1272w, https://substackcdn.com/image/fetch/$s_!LUTR!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd13b495f-1318-406c-bcca-c1ca07304fab_3168x1344.png 1456w" sizes="100vw" fetchpriority="high"></picture><div></div></div></a></figure></div><p>Three engineers at StrongDM built production security software in 2025 under two rules: no human writes code, no human reviews code. They shipped it. It's running in production.</p><p>I've been following this story since they published their methodology in February. My reaction was something between "obviously this is where things are headed" and "I genuinely don't know how I feel about that."</p><p>The domain matters here. StrongDM isn't building a todo app. They're building access management software &#8212; the kind that controls who can touch what across Okta, Jira, Slack, and Google Drive. If it has a flaw, the blast radius is real. The fact that no human reviewed the code doesn't make it smaller.</p><h2>The testing problem they actually solved</h2><p>The part that stuck with me: agents cheat. Not deliberately, but effectively. If a test checks whether a function returns a specific value, the agent will hardcode that value. Test passes. Software is broken. The model found the shortest path to green and didn't care whether it was useful.</p><p>This isn't a new problem. Goodhart's Law has been around since 1975. What's new is that the cheater is your software, and it's faster at gaming metrics than you are at writing them.</p><p>StrongDM's fix: treat validation like a machine learning holdout set. Store test scenarios completely outside the codebase, where the agent can't read them. Their evaluation framework tests user-level outcomes &#8212; did the software do what the user needed, not did this function return the right value.</p><blockquote><p>They call this measuring "satisfaction." I'd call it the right question.</p></blockquote><h2>The fake infrastructure play</h2><p>They also built behavioral clones of every third-party service the software integrates with. Full replicas of Okta, Jira, Slack, Google Drive &#8212; their APIs, edge cases, observable behaviors &#8212; running locally with no rate limits and no production risk. They call it a Digital Twin Universe.</p><p>With it, they run thousands of test scenarios per hour. The setup lets them:</p><ol><li><p>Simulate failure modes that would be dangerous to test against live systems</p></li><li><p>Run the same scenario thousands of times without rate limits</p></li><li><p>Have the agents building the software also build the testing environment</p></li></ol><p>Six months ago, faithfully replicating even one major SaaS API was economically absurd. Now it's table stakes for this team.</p><h2>The accountability question nobody has answered</h2><p>When no human has read the code, who's responsible for what it does?</p><p>There's no good answer yet. Stanford Law flagged it two days after StrongDM's announcement. Existing software liability frameworks assume a human made decisions about what shipped. The legal infrastructure for "the model decided" doesn't exist.</p><p>This matters for anyone building AI-first. Your outputs have consequences regardless of whether a human touched the code.</p><h2>The number</h2><blockquote><p>StrongDM's benchmark: if you're not spending at least $1,000 per engineer per day on tokens, your factory has room to improve.</p></blockquote><p>That's $20K/month per engineer in inference before salaries. The math works if three engineers can build and maintain production security software without reviewers. It doesn't work for most teams today.</p><p>But the cost comes down as models get cheaper, and the methodology &#8212; scenario holdouts, digital twins, probabilistic validation &#8212; scales in both directions.</p><p>Whether this becomes standard practice is a question 2026 is answering right now. I'm watching closely.</p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://newsletter.protomota.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Brad Dunlap's Substack is a reader-supported publication. To receive new posts and support my work, consider becoming a free or paid subscriber.</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div>]]></content:encoded></item><item><title><![CDATA[Your agent config is code. Start treating it like it.]]></title><description><![CDATA[Why your SKILL.md is infrastructure, not a preference &#8212; and what happens when you treat it that way.]]></description><link>https://newsletter.protomota.com/p/your-agent-config-is-code-start-treating</link><guid isPermaLink="false">https://newsletter.protomota.com/p/your-agent-config-is-code-start-treating</guid><dc:creator><![CDATA[Brad Dunlap]]></dc:creator><pubDate>Wed, 04 Mar 2026 15:48:57 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!Id5u!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F51b29ffa-1bd2-4a14-81a8-540dd09a7ae4_2752x1536.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<div class="captioned-image-container"><figure><a class="image-link image2" target="_blank" href="https://substackcdn.com/image/fetch/$s_!Id5u!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F51b29ffa-1bd2-4a14-81a8-540dd09a7ae4_2752x1536.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!Id5u!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F51b29ffa-1bd2-4a14-81a8-540dd09a7ae4_2752x1536.png 424w, https://substackcdn.com/image/fetch/$s_!Id5u!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F51b29ffa-1bd2-4a14-81a8-540dd09a7ae4_2752x1536.png 848w, https://substackcdn.com/image/fetch/$s_!Id5u!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F51b29ffa-1bd2-4a14-81a8-540dd09a7ae4_2752x1536.png 1272w, https://substackcdn.com/image/fetch/$s_!Id5u!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F51b29ffa-1bd2-4a14-81a8-540dd09a7ae4_2752x1536.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!Id5u!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F51b29ffa-1bd2-4a14-81a8-540dd09a7ae4_2752x1536.png" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/51b29ffa-1bd2-4a14-81a8-540dd09a7ae4_2752x1536.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:null,&quot;width&quot;:null,&quot;resizeWidth&quot;:728,&quot;bytes&quot;:null,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!Id5u!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F51b29ffa-1bd2-4a14-81a8-540dd09a7ae4_2752x1536.png 424w, https://substackcdn.com/image/fetch/$s_!Id5u!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F51b29ffa-1bd2-4a14-81a8-540dd09a7ae4_2752x1536.png 848w, https://substackcdn.com/image/fetch/$s_!Id5u!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F51b29ffa-1bd2-4a14-81a8-540dd09a7ae4_2752x1536.png 1272w, https://substackcdn.com/image/fetch/$s_!Id5u!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F51b29ffa-1bd2-4a14-81a8-540dd09a7ae4_2752x1536.png 1456w" sizes="100vw" fetchpriority="high"></picture><div></div></div></a></figure></div><p>Most people are still calling this "better prompting." I don't think that's right.</p><p>What's actually happening: we're moving from one-off instructions to software-defined environments. When I started building agent teams for my projects, I thought in terms of prompts. Now I think in terms of files. SKILL.md, CLAUDE.md, .clinerules, action schemas &#8212; each one is a deployable artifact with a specific scope and lifecycle.</p><p>The difference matters more than it sounds.</p><p>A prompt is temporary. A skill file is infrastructure. When I write a SKILL.md for one of my agents, I'm authoring behavior: what it does, in what order, under what constraints, and when it stops. That's configuration code. It belongs in version control, gets reviewed when it changes, and gets maintained like anything else in your stack.</p><p>I learned this the hard way. Early versions of my agent setups were sprawling instruction blobs I'd partially remember and inconsistently apply. New behavior introduced in one session would disappear in the next. The agent's reliability was inversely proportional to how long it had been since I'd touched the config.</p><p>Once I started treating these files like actual code &#8212; committed to git, organized by concern, with clear inheritance &#8212; everything got more predictable. Not because the models improved. Because the configuration did.</p><h2>The layering pattern</h2><p>A setup worth copying in 2026:</p><ol><li><p>A root config for global defaults and guardrails</p></li><li><p>Skill modules for work you do repeatedly</p></li><li><p>Project-level overrides that inherit from the base</p></li><li><p>Memory files that persist context across sessions</p></li></ol><p>One configuration for writing. Another for product work. Another for ops. Each tuned, versioned, owned by you.</p><blockquote><p>The alternative is relying on vibes and hoping the agent remembers what you told it last week.</p></blockquote><h2>The security surface nobody's thinking about</h2><p>If behavior lives in files, those files are part of your attack surface. Not theoretically &#8212; practically.</p><p>Third-party skill files should be treated like third-party dependencies:</p><ul><li><p>Review them before running them</p></li><li><p>Scope permissions to what's actually needed</p></li><li><p>Don't give an agent write access when it only needs to read</p></li></ul><p>Most people haven't applied basic hygiene standards to their agent configs yet. That gap will close, probably after something goes wrong.</p><h2>What to actually do</h2><p>Put your config files in git. Write commit messages that explain why a rule changed, not just what changed. Build skills that do one thing and can be tested in isolation.</p><p>Think through your precedence model deliberately: what's global, what's project-specific, what can a single user override. Those answers exist &#8212; they just require thinking it through rather than letting the config accumulate by accident.</p><blockquote><p>A well-built agent stack can be cloned, backed up, handed off, and iterated. It's your operating layer. Treat it like one.</p></blockquote><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://newsletter.protomota.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Brad Dunlap's Substack is a reader-supported publication. To receive new posts and support my work, consider becoming a free or paid subscriber.</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div>]]></content:encoded></item></channel></rss>