
Prompt to Drive a NASA Mars Rover: Building Physical AI for Space Exploration
Natural-language control of a NASA Curiosity rover — built on the Strands Agents powered by Amazon Nova model.
I typed "drive to the ridge, then report your pose" into a text box.
An Amazon Nova powered Strands agent read it, reasoned about it, picked a tool — and a NASA Curiosity rover rolled across Martian terrain in a high-fidelity Gazebo simulation.
Physical AI is that bridge. And to make it concrete, I pointed it at one of the most iconic robots ever built: NASA's Curiosity Mars rover.
That's the headline. The rest of this post is the engineering underneath it — because the demo is fun, but the architecture is the real story.
If you just want the code: https://github.com/Vivek0712/strands-robots-ros2

What I built
strands-robots-ros2 — an extension of Strands Robots that connects Strands Agents to ROS-based robots. Where the upstream framework focuses on manipulation, this adds the piece needed to drive mobile, ROS robots: a clean adapter layer so a Strands agent can command a robot over ROS2/DDS (and, for the Curiosity sim, ROS1 via rosbridge) without the LLM ever touching ROS itself.The flagship demo: an Amazon Nova agent driving an authentic NASA/JPL Curiosity rover model — six-wheel rocker-bogie, real meshes, physically-simulated Martian terrain — entirely in natural language.
The one idea that makes it all work
A robot should be an agent tool like any other tool.
The LLM never imports
rclpy. It never opens a socket. It calls one tool — Robot — exactly the way it'd call a calculator or a web-search tool:1
2
3
4
5
6
from strands import Agent
from strands_robots_ros2 import Robot
Agent(tools=[Robot("curiosity", robot="curiosity")])(
"Drive Curiosity forward 4 meters, turn left, then report your pose."
)Behind that one line, the framework picks the right way to reach the rover, runs a 50 Hz control loop, and translates the agent's intent into motion. The agent speaks one normalized contract:
1
{"linear_x": 0.5, "angular_z": 0.3} # a body-frame twist…and the runtime handles the wire. The agent's job is to reason; the framework's job is to drive.
The pipeline: prompt → Nova → rover
1
2
3
4
5
6
7
Natural language ("drive to the ridge")
↓
Strands Agent (Amazon Nova on Amazon Bedrock)
↓
Robot tool (strands-robots-ros2)
↓
ROS runtime → Curiosity rover (Gazebo · Mars terrain)You type. Nova reasons and picks a tool. The framework executes it on the rover at 50 Hz. The rover moves. Live, in your browser.
The gnarly bit that made Curiosity work
Here's the real engineering problem behind the smooth demo.
The Curiosity simulation is ROS1 Noetic, which is Python 3.8. Modern Strands Agents needs Python 3.10+ — I run the agent on 3.12. You cannot import both ROS worlds into one process, and the classic
ros1_bridge is a brittle two-distribution build.The fix: run a rosbridge server inside the simulation container and drive it from the agent over a WebSocket using
roslibpy. No rclpy in the agent process, no Python-version collision, single process. The rover becomes a small registry entry (runtime: "rosbridge"), and:1
Robot("curiosity").move(linear_x=0.4, angular_z=0.0, duration=4.0)…auto-connects and the rover physically traverses the terrain — descending a slope (observed odometry z: −2.5 → −2.9 m).
That's the payoff of treating the transport as a swappable layer: a whole new way to reach a robot (ROS1 over WebSocket) was added without touching the agent or the rest of the framework.
Two ways to drive — one agent, one toolbox
The agent chooses the right tool for the prompt:
1
2
3
4
5
6
7
8
9
10
from strands_robots_ros2 import Robot
from strands_robots_ros2.skills.nav2 import navigate_to, get_pose, cancel_navigation
# Teleop: the agent composes primitive moves
Agent(tools=[Robot("curiosity", robot="curiosity")])(
"Drive forward, then turn to face the ridge, then report your pose.")
# Autonomous: Nav2 path-planning + live SLAM mapping on a ROS2 rover
Agent(tools=[Robot("turtlebot4"), navigate_to, get_pose, cancel_navigation])(
"Navigate to (3.0, 1.5), avoid obstacles, then report your pose.")- 🕹️
move(linear_x, angular_z)drives the six-wheel rocker-bogie across Martian terrain. - 🧭
navigate_to(x, y)runs full Nav2 path-planning + obstacle avoidance + live SLAM mapping on ROS2 rovers. - 📍
get_pose()reports where the rover thinks it is.
Same agent API. The framework handles whether that means a teleop twist or a planned, mapped route.
Safety lives at the boundary
LLMs hallucinate. So safety is enforced where the command becomes motion — in the runtime, not in the agent:
- Clamping to per-robot
safety_limits(max velocity, max duration). - A watchdog-beating trailing zero:
movealways emits a stop on exit, beating the ROS controller's command timeout. - Auto-zero on exception, cancel, or timeout — a wedged sensor or a stalled call stops the rover rather than latching the last velocity.
Robots as data, not code
This is the design thesis made concrete: adding a robot is a configuration change, not new code.
The Curiosity rover is a JSON registry entry — roughly 37 lines, zero lines of Python:
1
2
3
4
5
6
7
"curiosity": {
"runtime": "rosbridge",
"command_topic": "/curiosity_mars_rover/ackermann_drive_controller/cmd_vel",
"odom_topic": "/curiosity_mars_rover/odom",
"safety_limits": { "max_linear_x": 0.5, "max_angular_z": 1.0 },
"aliases": ["curiosity_rover", "msl", "mars_rover"]
}Want a different ROS rover? It's another registry entry — the agent code, the tool, and the control loop never change. That's the whole point of building it as an extension of Strands Robots: the agent-tool conventions stay the same, and ROS robots slot in as configuration.
A real (and honest) finding from Mars
One detail worth sharing, because it's the kind of thing the demo hides: an imported high-detail terrain mesh is great for physics and rendering, but it breaks a CPU ray-lidar. Casting 360 rays at 10 Hz against a dense collision mesh stalls — so
/scan gets advertised but never publishes, which is exactly why real Mars simulators use ray-cast-optimized heightmaps instead of raw meshes.The teleop and agent→rover control path are unaffected by that; it only matters for the autonomous-mapping route. I'm flagging it because honest engineering notes are how this stays credible.
What's real, and what's next
- Real and tested: the agent→rover control path over rosbridge; natural-language teleop driving the Curiosity sim end-to-end; Nav2 + live SLAM on ROS2 rovers in Gazebo.
- Simulation, not physical hardware (the Curiosity sim is a third-party ROS asset; the rover models are NASA/JPL-Caltech).
- The honest target isn't "everything is built." It's a clean seam for ROS robots, so the next rover is configuration, not a fork.
A companion paper, "Design Principles of Agent-Powered Robotics Systems," is pending publication on arXiv — it formalizes the design and this case study.
Get started
1
pip install "strands-robots-ros2[rosbridge]"1
2
3
4
5
from strands import Agent
from strands_robots_ros2 import Robot
Agent(tools=[Robot("curiosity", robot="curiosity")])(
"Drive Curiosity to the ridge, then report your pose.")- ⭐ Code, demos, and the web UI: https://github.com/Vivek0712/strands-robots-ros2
- 📄 Paper: Design Principles of Agent-Powered Robotics Systems — pending on arXiv
Built on the Strands Agents SDK and Amazon Nova on Amazon Bedrock, extending Strands Robots to ROS-based robots. Curiosity rover models courtesy NASA/JPL-Caltech; simulation via the open-source
curiosity_mars_rover Gazebo project.An AI agent, a sentence, and a Mars rover. Where should it drive next? 🛰️
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article