Robot Deliberation in ROS 2 with Behavior Trees
Getting Started
If you’re building a robot that does anything non-trivial, you quickly realise that having working perception, planning, and control code is only half the battle. Something still needs to decide when to call what, how to recover when things go wrong, and in what order the whole workflow unfolds. That’s the job of a deliberation layer — sometimes called an executive or task orchestrator. All examples in this post have a working implementation in the companion repository on GitHub.
In ROS 2, two approaches dominate this space: Hierarchical Finite State Machines (HFSMs) and Behaviour Trees (BTs).
If you’ve ever used FlexBE or SMACH, you’ve used an HFSM. The idea is simple: define a set of states and transitions between them triggered by events or outcomes. Intuitive for small workflows, but with a scaling problem. The logic of what to do next lives in the transitions, so every state needs to know about every other state it might need to reach. Add a new fault recovery case and you may need to rewire transitions across many otherwise-unrelated states. Coupling grows with complexity.
Behaviour Trees take a different approach.
Instead of encoding “where to go next” in the edges of a graph, you build a tree of control flow nodes that tick their children on every cycle. Each child reports back SUCCESS, FAILURE, or RUNNING, and the parent immediately decides what to tick next.
This two-way handshake is what gives BTs their key property: any subtree can be lifted out and reused elsewhere without modification.
This modularity is why BTs caught on first in the games industry (NPC AI) and have since become popular in mobile manipulation, autonomous driving, and human-robot collaboration. BTs have also been shown to subsume HFSMs, subsumption architectures, and decision trees as special cases; you lose no expressive power, but you gain composability.
Concepts
Node Types
BehaviorTree.CPP from Davide Faconti uses three categories of node, worth knowing before reading any BT diagram (docs here).
Control flow nodes: route ticks to their children:
- Sequence (
→) — ticks children left-to-right; returnsFAILUREon the first failure,SUCCESSonly once all children succeed. Think of it as a logical AND. - Fallback (
?) — ticks left-to-right; returnsSUCCESSon the first success,FAILUREonly if all children fail. Think of it as a logical OR, or a “try this, then this, then this” pattern.
Decorator nodes: wrap a single child and modify its return value. The most common example is Repeat, which re-ticks its child a fixed number of times.
Leaf nodes: do the actual work:
- Action nodes call a ROS action or service and return when the call resolves.
- Condition nodes evaluate a predicate; checking a value on the blackboard or a subscribed topic.
- SubTree nodes splice an entire BT (defined in a separate XML file) into the calling tree, which is where the composability becomes concrete.
One note on terminology: BT nodes and ROS nodes are completely separate things. In this post “node” on its own refers to BT nodes unless stated otherwise.
Figure 1: BT node type visual conventions used throughout this post.
Figure 2: A single tick cycle. The root ticks the Sequence; the Sequence ticks Action A, which returns RUNNING; RUNNING propagates back up. Actions B and C are not ticked until A completes.
The Blackboard
All nodes in a BT share a key-value store called the blackboard.
This is how data flows through the tree; one node writes a result (say, a target pose), and a downstream node reads it by key name.
In the XML, blackboard keys are typically wrapped in curly braces: {my_key}.
In Practice
There are three moving parts to wire up before you can tick a tree: a runner executable, a node registration file, and the leaf node headers themselves.
The Runner
The runner is a standard ROS 2 node whose only job is to own the factory, load the XML, and drive the tick loop.
It accepts tree_file and tick_rate_hz as ROS parameters so you can swap trees at launch time without recompiling.
The full runner with loggers and failure tracking is bt_runner.cpp.
#include <filesystem>
#include <string>
#include <thread>
#include <behaviortree_cpp_v3/bt_factory.h>
#include <behaviortree_cpp_v3/loggers/bt_cout_logger.h>
#include <rclcpp/executors/multi_threaded_executor.hpp>
#include <rclcpp/rclcpp.hpp>
#include "my_bt/register_nodes.hpp"
int main(int argc, char ** argv)
{
rclcpp::init(argc, argv);
auto node = std::make_shared<rclcpp::Node>("bt_runner");
node->declare_parameter<std::string>("tree_file", "");
node->declare_parameter<double>("tick_rate_hz", 10.0);
std::string tree_file = node->get_parameter("tree_file").as_string();
double tick_rate = node->get_parameter("tick_rate_hz").as_double();
if (tree_file.empty() || !std::filesystem::exists(tree_file)) {
RCLCPP_FATAL(node->get_logger(), "tree_file parameter is missing or does not exist");
return 1;
}
BT::BehaviorTreeFactory factory;
my_bt::registerNodes(factory, node);
auto tree = factory.createTreeFromFile(tree_file);
BT::StdCoutLogger cout_logger(tree);
rclcpp::executors::MultiThreadedExecutor executor;
executor.add_node(node);
std::thread spin_thread([&executor]() { executor.spin(); });
rclcpp::Rate rate(tick_rate);
BT::NodeStatus status = BT::NodeStatus::RUNNING;
while (rclcpp::ok() && status == BT::NodeStatus::RUNNING) {
status = tree.tickRoot();
rate.sleep();
}
executor.cancel();
spin_thread.join();
rclcpp::shutdown();
return status == BT::NodeStatus::SUCCESS ? 0 : 1;
}
A MultiThreadedExecutor running on a background thread is important: BT.CPP’s AsyncActionNode spins its own internal thread, but your leaf nodes still need a live ROS executor to process action/service callbacks while a tick is in flight.
Registering Nodes
The factory maps the string IDs in your XML to C++ types.
Split this into its own register_nodes.hpp / register_nodes.cpp pair so the runner stays thin and you can unit-test registration separately.
The standard pattern uses NodeBuilder lambdas to capture whatever the node needs at construction time (usually the shared rclcpp::Node):
// register_nodes.cpp
#include "my_bt/register_nodes.hpp"
#include "my_bt/my_action_node.hpp"
#include "my_bt/my_service_node.hpp"
namespace my_bt
{
void registerNodes(BT::BehaviorTreeFactory& factory, rclcpp::Node::SharedPtr node)
{
BT::NodeBuilder action_builder =
[node](const std::string& name, const BT::NodeConfiguration& cfg) {
return std::make_unique<MyActionNode>(name, cfg, node);
};
factory.registerBuilder<MyActionNode>("MyAction", action_builder);
BT::NodeBuilder service_builder =
[node](const std::string& name, const BT::NodeConfiguration& cfg) {
return std::make_unique<MyServiceNode>(name, cfg, node);
};
factory.registerBuilder<MyServiceNode>("MyService", service_builder);
}
} // namespace my_bt
If a node needs something other than the ROS node; a shared recorder, a config struct, a hardware handle; just add it to the lambda capture.
Custom Leaf Node Headers
BT.CPP provides three base classes worth knowing to start with.
BT::SyncActionNode — runs to completion in a single tick() call.
Use this for anything fast and non-blocking (setting a blackboard value, checking a condition, calling a trivially quick service).
BT::StatefulActionNode — for operations that take time.
The tree keeps ticking everything else while your node is RUNNING; onRunning() is called every tick until you return SUCCESS or FAILURE.
onHalted() is called if the tree cancels you mid-flight (e.g. a parent Fallback succeeds via a sibling).
Template base classes for action and service clients — wrapping ROS action and service calls is repetitive enough to be worth templating.
A base class handles server discovery, goal sending, result polling, and cancel propagation; subclasses override one method to build the goal from input ports and another to write the result back to the blackboard. Nav2 ships exactly this pattern as BtActionNode<ActionT> in the nav2_behavior_tree package — the design and tradeoffs are covered in detail in the Composing Behaviours section below.
The runner section above uses MultiThreadedExecutor precisely so these futures can resolve while the tick is in flight.
A Minimal Example
With the boilerplate in place, here is a worked example covering the three things you’ll reach for almost immediately: a ROS-native status logger, reading a pose from a topic into the blackboard, and an XML tree that ties them together.
Loggers
BT.CPP ships BT::StdCoutLogger out of the box — attach it to the tree and every node status transition prints to stdout.
For ROS you’ll want transitions going to RCLCPP_INFO instead, which is a ten-line StatusChangeLogger subclass (rclcpp_logger.hpp):
class RclcppLogger : public BT::StatusChangeLogger
{
public:
RclcppLogger(BT::TreeNode* root, rclcpp::Logger lg)
: BT::StatusChangeLogger(root), lg_(lg) {}
void callback(BT::Duration, const BT::TreeNode& node,
BT::NodeStatus prev, BT::NodeStatus next) override
{
RCLCPP_INFO(lg_, "[BT] %s : %s -> %s",
node.name().c_str(),
BT::toStr(prev).c_str(),
BT::toStr(next).c_str());
}
void flush() override {}
private:
rclcpp::Logger lg_;
};
// After factory.createTreeFromFile():
RclcppLogger rclcpp_logger(tree.rootNode(), node->get_logger());
Topic Pose to the Blackboard
PoseTopicWait subscribes to a geometry_msgs/PoseStamped topic and writes the first received message to an output port, holding RUNNING until a message arrives (or a timeout fires):
class PoseTopicWait : public BT::StatefulActionNode
{
public:
static BT::PortsList providedPorts()
{
return {
BT::InputPort<std::string>("topic"),
BT::InputPort<double>("timeout_s", 0.0, "0 = wait forever"),
BT::InputPort<bool>("latched", false, "use transient_local QoS"),
BT::OutputPort<geometry_msgs::msg::PoseStamped>("pose"),
};
}
// onStart() creates the subscription; onRunning() polls until a message arrives
};
Putting it Together
With Sleep, PoseTopicWait, PublishLog, and SetBlackboard registered, the smallest useful tree looks like this (minimal_example.xml).
Note that SetBlackboard is registered automatically by the factory, but Sleep is not registered by default in the ros-jazzy-behaviortree-cpp-v3 package and must be registered explicitly (see the registration section):
<?xml version="1.0"?>
<root>
<BehaviorTree ID="MinimalExample">
<Sequence>
<!-- Wait for a live pose to arrive on a topic; write it to {target_pose} -->
<PoseTopicWait topic="/robot/target_pose"
timeout_s="10.0"
pose="{target_pose}"/>
<!-- Brief pause, then log that we are ready -->
<Sleep msec="500"/>
<PublishLog topic="/bt/log"
msg="Pose received, ready to move"
level="INFO"/>
</Sequence>
</BehaviorTree>
</root>
After PoseTopicWait succeeds, {target_pose} is available to any downstream node in the same tree; no code changes needed, just XML.
That minimal tree works, but a flat list of leaf nodes becomes unmanageable fast as complexity grows. One design pattern that scales is a four-level hierarchy.
Behaviours, Skills, Primitives
This is much simpler than many real world behaviors, for which a flat tree of leaf nodes would become unmanageable fast. The pattern that scales well is a four-level hierarchy that maps to the primitives → skills → tasks abstraction from skill-based robot programming:
- Atomic skills — lowest unit of reuse; each wraps a single ROS action or service with some recovery control flow around it.
- Set motions — parameterised skills that fix a particular motion configuration or named pose, e.g. “home the base”.
- Phase subtrees — reusable sequences that combine skills into meaningful phases.
- Top-level trees — one per task; composes the phase subtrees into the full robot behavior, often encompassing some kind of permanent loop.
Figure 3: Four-level BT hierarchy. Only the skills layer needs to change if the underlying robot is swapped.
The practical payoff of this separation is that the skills layer (wrapping your ROS action interfaces) is the only layer that needs to change if you swap out the underlying hardware. The subtrees and top-level trees express what is being done and in what order, and stay hardware-agnostic.
Ports and Port Remapping
In the simple example we touched on the idea of ports.
Every leaf node in BT.CPP declares ports; named input and output slots through which it reads from and writes to the blackboard.
A MoveItPlan node might declare an input port pose_goal and an output port trajectory; a downstream MoveItExecute node declares an input port trajectory and reads the value that MoveItPlan wrote.
Port remapping is what makes subtrees reusable.
When you package a sequence of nodes into a subtree, the subtree’s own blackboard is isolated from the parent’s; a key called trajectory inside the subtree is not the same entry as trajectory in the parent.
Remapping creates an explicit bridge between the two: it says “the port called X inside this subtree should read/write the key called Y in my blackboard.”
Without remapping, every subtree would need its leaf nodes hard-coded to the exact key names used by the caller, which defeats the point of having reusable subtrees.
With remapping, the same MoveBase subtree can be called from multiple places in the tree, each time pointed at a different blackboard key for the goal pose.
Port mapping gotcha in BT.CPP v3.8: SubTree and SubTreePlus handle remapping differently, and the syntax is easy to mix up:
<SubTree ID="MoveBase" base_loc="topic_base_loc"/>— remaps the parent keytopic_base_locinto the subtree (note the lack of curly braces).<SubTreePlus ID="MoveBase" base_loc="{topic_base_loc}"/>— remaps the subtree’sbase_locport reads/writes the parent’stopic_base_lockey.<SubTreePlus ID="MoveBase" __autoremap="1"/>— automatically remaps all ports whose names match between parent and subtree, saving boilerplate when the keys happen to share names.
Move Base to Topic
The MoveBase subtree checks whether the base is already at the target pose before commanding motion, and the BaseToTopic tree reads the target from a ROS topic then calls it.
<?xml version="1.0"?>
<root>
<BehaviorTree ID="MoveBase">
<Fallback name="move_base_fallback">
<CheckPose name="base_check_location"
child_frame="base_link"
pose="{base_loc}"
max_pos_error="0.01"
max_rot_error="0.044"/> <!-- ~5 degs -->
<BaseToGoal name="base_to_location" pose="{base_loc}"/>
</Fallback>
</BehaviorTree>
</root>
Figure 4: MoveBase: check whether already at the goal before commanding motion.
<?xml version="1.0"?>
<root>
<include path="./move_base.xml" />
<BehaviorTree ID="BaseToTopic">
<Sequence name="base_to_topic">
<PoseStampedTopicToBBPose name="get_base_pose" pose_stamped_topic_name="{pose_stamped_topic_name}" pose="{topic_base_loc}"/>
<SubTree ID="MoveBase" base_loc="topic_base_loc"/>
</Sequence>
</BehaviorTree>
</root>
Figure 5: BaseToTopic: read a pose from a ROS topic then delegate to MoveBase.
Manipulator Joint Space Move
When the destination is a named robot configuration — a pose defined by name in the SRDF rather than as a Cartesian end-effector target — joint-space planning is more direct than Cartesian planning.
There is no inverse kinematics to solve and no risk of hitting a configuration singularity or an ambiguous IK solution.
Common examples are Home, Retract, and Vertical, which robot vendors typically ship in their MoveIt SRDF.
This example also introduces an important BT idiom: check before acting.
Rather than commanding a move unconditionally, a Fallback first checks whether the arm is already at the target configuration.
If the check succeeds the Fallback returns SUCCESS immediately; the move only runs if the check fails.
This makes the subtree idempotent; calling it repeatedly is safe and efficient.
CheckNamedConfig reads the current joint values from MoveGroupInterface::getCurrentJointValues(), retrieves the target joint values from getNamedTargetValues(), and returns SUCCESS if every joint in the named state is within tolerance radians of its current position.
class CheckNamedConfig : public BT::StatefulActionNode
{
public:
static BT::PortsList providedPorts()
{
return {
BT::InputPort<std::string>("planning_group", "manipulator", "MoveIt planning group"),
BT::InputPort<std::string>("named_target", "home", "Named state from the robot SRDF"),
BT::InputPort<double> ("tolerance", 0.01, "Per-joint tolerance in radians"),
};
}
// onStart() spawns a thread that compares getCurrentJointValues() to getNamedTargetValues().
// onRunning() polls; returns SUCCESS if all joints are within tolerance, FAILURE otherwise.
};
MoveItNamedTarget wraps MoveGroupInterface::setNamedTarget() in the same async planning pattern as MoveItPlan.
Its output port type is identical to MoveItPlan, so MoveItExecute can consume either plan without modification — named-target and Cartesian plans are interchangeable at the execution step.
class MoveItNamedTarget : public BT::StatefulActionNode
{
public:
static BT::PortsList providedPorts()
{
return {
BT::InputPort<std::string>("planning_group", "manipulator", "MoveIt planning group name"),
BT::InputPort<std::string>("named_target", "home", "Named state from the robot SRDF"),
BT::InputPort<double> ("planning_time", 5.0, "Max planning time in seconds"),
BT::OutputPort<moveit::planning_interface::MoveGroupInterface::Plan>("plan"),
};
}
// onStart() calls mgi.setNamedTarget(named_target) then launches a planning thread.
// onRunning() polls the thread result; onHalted() joins it on cancellation.
};
The XML tree (move_joint_target.xml) wraps the plan-and-execute sequence in a Fallback with the config check as its first child:
<?xml version="1.0"?>
<root main_tree_to_execute="Main">
<BehaviorTree ID="MoveJointTarget">
<Fallback name="move_if_needed">
<CheckNamedConfig
planning_group="{planning_group}"
named_target="{named_target}"
tolerance="{tolerance}"/>
<Sequence name="plan_and_execute">
<MoveItNamedTarget
planning_group="{planning_group}"
named_target="{named_target}"
plan="{arm_plan}"/>
<MoveItExecute
plan="{arm_plan}"
execute_action="{execute_action}"/>
</Sequence>
</Fallback>
</BehaviorTree>
<BehaviorTree ID="Main">
<Sequence name="root">
<SetBlackboard output_key="planning_group" value="manipulator"/>
<SetBlackboard output_key="named_target" value="Home"/>
<SetBlackboard output_key="tolerance" value="0.01"/>
<SetBlackboard output_key="execute_action" value="execute_trajectory"/>
<SubTreePlus ID="MoveJointTarget"
planning_group="{planning_group}"
named_target="{named_target}"
tolerance="{tolerance}"
execute_action="{execute_action}"/>
</Sequence>
</BehaviorTree>
</root>
Figure 6: MoveJointTarget: check the current configuration and only move if needed.
Manipulator MoveIt! Plan and Execute
This example builds up four composable trees — SwitchControllerWithSleep, CheckSetController, CheckSetKinovaController, and MoveArmMoveIt — showing how SubTree composition turns low-level controller switching into a reusable, hardware-specific skill that higher-level trees can call without knowing the details.
<?xml version="1.0"?>
<root>
<BehaviorTree ID="SwitchControllerWithSleep">
<Sequence>
<SetBlackboard output_key="sleep" value="1000"/>
<Sleep msec="{sleep}"/>
<SwitchController
activate="{activated_controller}"
activate_asap="0"
deactivate="{deactivated_controller}"
service_name="{controller_srv}"
strict="true"
timeout_s="5.0"/>
<Sleep msec="{sleep}"/>
</Sequence>
</BehaviorTree>
</root>
Figure 7: SwitchControllerWithSleep: brief sleeps bracket the switch call to let the controller manager settle.
<?xml version="1.0"?>
<root>
<include path="./switch_controller_with_sleep.xml" />
<BehaviorTree ID="CheckSetController">
<Sequence name="check_set_controller">
<Fallback name="check_set_controller_fallback">
<CheckControllerState
controller="{goal_controller}"
controller_manager="{controller_manager}"
timeout_s="5.0"
active_controller="{active_controller}"/>
<SubTreePlus
ID="SwitchControllerWithSleep"
activated_controller="{goal_controller}"
controller_srv="{controller_srv}"
deactivated_controller="{active_controller}"/>
</Fallback>
</Sequence>
</BehaviorTree>
</root>
Figure 8: CheckSetController: only switch if the desired controller isn’t already active.
<?xml version="1.0"?>
<root>
<include path="./check_set_controller.xml" />
<BehaviorTree ID="CheckSetKinovaController">
<Sequence name="check_set_kinova_controller">
<SetBlackboard output_key="controller_manager" value="/kinova_/controller_manager"/>
<SetBlackboard output_key="controller_srv" value="/kinova_/controller_manager/switch_controller"/>
<SubTreePlus
ID="CheckSetController"
goal_controller="{goal_controller}"
controller_manager="{controller_manager}"
controller_srv="{controller_srv}"/>
</Sequence>
</BehaviorTree>
</root>
Figure 9: CheckSetKinovaController: fix the Kinova-specific controller manager paths then delegate.
<?xml version="1.0"?>
<root>
<include path="./check_set_kinova_controller.xml" />
<BehaviorTree ID="MoveArmMoveIt">
<Sequence name="move_arm_moveit">
<SetBlackboard output_key="move_group" value="kinova_arm"/>
<SetBlackboard output_key="goal_controller" value="joint_trajectory_controller"/>
<Fallback name="move_if_not_at_target">
<CheckArmConfigurationPoseSpace
ee_link="{ee_link}"
target_pose="{target_pose}"
position_tol_m="0.005"
orientation_tol_deg="1.0"/>
<Sequence name="plan_switch_execute">
<MoveItPlan
planning_group="{move_group}"
pose="{target_pose}"
plan="{traj}"
planning_pipeline="pilz_industrial_motion_planner"
planner_id="LIN"
velocity_scale="0.1"
num_attempts="100"
accel_scale="0.1"
planning_time="50.0"/>
<SubTreePlus
ID="CheckSetKinovaController"
goal_controller="{goal_controller}"/>
<MoveItExecute
plan="{traj}"
execute_action="{execute_action}"
timeout_s="30.0"/>
</Sequence>
</Fallback>
</Sequence>
</BehaviorTree>
</root>
Figure 10: MoveArmMoveIt: skip planning entirely if already at the target pose; ensure the right controller is active before executing.
Composing Behaviours: SubTree vs Action-as-Node
The MoveArmMoveIt tree above is a good example of SubTree composition in practice: CheckSetKinovaController and SwitchControllerWithSleep are embedded as SubTree nodes, spliced directly into the parent’s tick loop with typed blackboard ports and no IPC overhead.
That works well when all the behaviours live in the same process. Once a behaviour needs to be reachable from outside the BT — a mission planner, RViz, or a test harness — the architectural choice changes.
There is a second compositional style: wrapping a ROS 2 action server as a BT leaf node.
Rather than embedding a subtree, a leaf node holds an rclcpp_action::Client and drives the action server asynchronously — sending the goal on first tick, returning RUNNING on subsequent ticks while the action is in flight, and reading the result on completion.
How each works
SubTree — the engine splices child nodes directly into the running tick loop. Blackboard ports are remapped at the call site; data flows through typed ports with no serialisation.
<SubTree ID="NavigateToPose"
goal="{target_pose}"
result="{nav_result}"/>
Action-as-Node — a BT leaf node holds an rclcpp_action::Client.
The first tick sends the goal; each subsequent tick polls the async future and returns RUNNING; the result is read on completion.
class NavigateToPoseAction
: public BtActionNode<nav2_msgs::action::NavigateToPose>
{
void on_tick(); // build goal from blackboard input ports
BT::NodeStatus on_success(); // parse result, write back to blackboard
};
Comparison
| Dimension | SubTree | Action-as-Node |
|---|---|---|
| External callability | Parent BT only | Any ROS 2 caller |
| Tick loop | Shared with parent | Separate, decoupled rate |
| Data sharing across boundary | Direct typed port remapping | Must serialise into goal/result IDL |
| Parent visibility into child | Full — every node status visible | Opaque — only RUNNING until done |
| Halt / preemption | Instant propagation | Action cancel handshake (latency) |
| Independent lifecycle | Coupled to parent | Own node, params, logging |
| Isolated testability | Needs full parent context | Send a goal directly |
| IPC overhead | None — function call | Action handshake per invocation |
| Groot / debug view | Full tree in one view | Each server shown separately |
Figure 11: SubTree keeps everything in one process with a shared tick loop. Action-as-Node crosses a process boundary; the behaviour runs its own tick loop and is contacted via ROS 2 action IPC.
A concrete cost: data across the action boundary
Because typed data cannot cross the action boundary directly, results often come back as free-text strings that need to be parsed back into structured values. A node might reconstruct boolean flags by string-matching an error message field returned in the action result:
bool is_goal_occupied = false;
if (result_.result->error_msg.find("GOAL_OCCUPIED") != std::string::npos) {
is_goal_occupied = true;
}
setOutput("is_goal_occupied", is_goal_occupied);
With a SubTree, is_goal_occupied would simply be a typed blackboard port — no parsing needed.
Using typed error codes in the action IDL (an enum rather than a free string) eliminates this fragility without changing the architecture.
When to use each
Prefer SubTree when:
- The behaviour is purely internal — no external callers needed
- You need rich typed data to flow across the boundary without serialisation overhead
- The parent needs to react to intermediate child state mid-execution
- Instant halt is required — e.g. safety-critical abort paths
- You want a single Groot view of the full composite tree
Prefer Action-as-Node when:
- The behaviour needs external callers beyond the BT (mission planners, RViz, CLI, test harnesses)
- You want independent lifecycle — restart, reconfigure, and monitor each behaviour separately
- The stack is already built on action server composition (nav2, MoveIt 2)
- You need decoupled tick rates between parent and child trees
- The behaviour must be testable in isolation without constructing parent BT context
In nav2-based systems, the right default is Action-as-Node — the stack is designed around it, and keeping your own behaviours as action servers makes them callable from mission planners, RViz, or test harnesses without any changes to the behaviour itself.
Monitoring and Debugging
Groot
Groot is the visual BT editor and live monitor. Groot 1 (open-source, ships alongside BT.CPP v3.x) connects to a running tree over ZMQ and renders per-node status in real time. Groot 2 (closed-source, targets v4+) adds a better editor, scripting support, and a maintained update cycle — but requires a licence for commercial use. For new v4 projects Groot 2 is the natural choice; for v3.8 or open-source work Groot 1 remains fully functional.
Live Tree Monitor (ZMQ)
Wrap the ZMQ publisher in a build flag so Groot can connect to a running robot without pulling the dependency into production builds:
#ifdef GROOTLOG
#include <behaviortree_cpp_v3/loggers/bt_zmq_publisher.h>
#endif
#ifdef GROOTLOG
BT::PublisherZMQ publisher_zmq(tree);
#endif
Palette Export
Write the node model to XML at startup so Groot can render node types it has not yet seen. Guard with a build flag and return early — this is a one-shot tool, not part of the normal run loop:
#ifdef PALETTE
#include <behaviortree_cpp_v3/xml_parsing.h>
#include <fstream>
#endif
#ifdef PALETTE
std::ofstream file("palette.xml");
file << BT::writeTreeNodesModelXML(factory);
file.close();
return;
#endif
Custom Port Types
Blackboard ports accept any type that has a convertFromString<T> specialisation registered with BT.CPP.
For primitive types this ships out of the box; for ROS message types (PoseStamped, RobotState, trajectory messages) you need to write the specialisation yourself and make it visible at the point of instantiation.
A practical pattern: expose a processPort<T>() helper on a custom base class (bt_node_bases.hpp) — moving the getInput boilerplate into the base so subclasses make a single typed call per port.
The convertFromString<T> specialisations live in a shared bt_type_converters.hpp header. The explicit template instantiations of processPort<T> live in a companion bt_node_bases.cpp that includes bt_type_converters.hpp, so the full getInput<T> call chain (which is inline in BT.CPP’s tree_node.h) is compiled in a translation unit where those specialisations are visible, rather than against the primary template which throws.
Base Node with Typed Port Reading
An example from a production ROS 1 codebase illustrates the pattern:
#pragma once
#include <behaviortree_cpp_v3/action_node.h>
#include "iiwajointposition_bb_parser.h"
#include "posestamped_bb_parser.h"
class SyncActionNode : public BT::SyncActionNode
{
public:
SyncActionNode(const std::string& name, const BT::NodeConfiguration& config);
template <typename PortType>
void processPort(PortType& port_value, const std::string& port_name);
};
///////////////////////////////////////////////////////////////////////////////////////////////////////////
#include "sync_action_node.h"
SyncActionNode::SyncActionNode(const std::string& name, const BT::NodeConfiguration& config) :
BT::SyncActionNode(name, config)
{
}
template <typename PortType>
void SyncActionNode::processPort(PortType& port_value, const std::string& port_name)
{
auto res = getInput<PortType>(port_name);
if (!res)
{
throw BT::RuntimeError("Error reading port [", port_name, "]: ", res.error());
}
port_value = res.value();
}
template void SyncActionNode::processPort<iiwa_msgs::JointPosition>(iiwa_msgs::JointPosition& port_value, const std::string& port_name);
template void SyncActionNode::processPort<geometry_msgs::PoseStamped>(geometry_msgs::PoseStamped& port_value, const std::string& port_name);
template void SyncActionNode::processPort<std::string>(std::string& port_value, const std::string& port_name);
template void SyncActionNode::processPort<bool>(bool& port_value, const std::string& port_name);
template void SyncActionNode::processPort<int>(int& port_value, const std::string& port_name);
template void SyncActionNode::processPort<float>(float& port_value, const std::string& port_name);
template void SyncActionNode::processPort<double>(double& port_value, const std::string& port_name);
The companion package uses the same three-file structure. The converter header (bt_type_converters.hpp) specialises convertFromString for PoseStamped:
#pragma once
#include <behaviortree_cpp_v3/action_node.h>
#include <geometry_msgs/msg/pose_stamped.hpp>
namespace BT
{
template <> inline geometry_msgs::msg::PoseStamped convertFromString(StringView str)
{
// Expects: "frame_id;x;y;z;qx;qy;qz;qw"
auto parts = splitString(str, ';');
if (parts.size() != 8)
{
throw RuntimeError("invalid input");
}
geometry_msgs::msg::PoseStamped output;
output.header.frame_id = convertFromString<std::string>(parts[0]);
output.pose.position.x = convertFromString<double>(parts[1]);
output.pose.position.y = convertFromString<double>(parts[2]);
output.pose.position.z = convertFromString<double>(parts[3]);
output.pose.orientation.x = convertFromString<double>(parts[4]);
output.pose.orientation.y = convertFromString<double>(parts[5]);
output.pose.orientation.z = convertFromString<double>(parts[6]);
output.pose.orientation.w = convertFromString<double>(parts[7]);
return output;
}
} // namespace BT
The base class header (bt_node_bases.hpp) declares processPort but deliberately omits the definition:
#pragma once
#include <string>
#include <behaviortree_cpp_v3/action_node.h>
namespace bt_deliberation_examples
{
/**
* Base class for BT::SyncActionNode descendants that need to read non-primitive
* types (PoseStamped, RobotState, etc.) from input ports.
*
* processPort<T>() is defined in bt_node_bases.cpp alongside bt_type_converters.hpp.
* This forces the full getInput<T> inline chain (BT.CPP v3's tree_node.h) to be
* compiled in a TU where the convertFromString<T> specialisations are visible,
* rather than against the primary template which throws.
*/
class BtExamplesSyncActionNode : public BT::SyncActionNode
{
public:
BtExamplesSyncActionNode(
const std::string & name, const BT::NodeConfiguration & config)
: BT::SyncActionNode(name, config) {}
template<typename PortType>
[[nodiscard]] bool processPort(PortType & port_value, const std::string & port_name);
};
/**
* Same as BtExamplesSyncActionNode but for BT::StatefulActionNode descendants.
*/
class BtExamplesStatefulActionNode : public BT::StatefulActionNode
{
public:
BtExamplesStatefulActionNode(
const std::string & name, const BT::NodeConfiguration & config)
: BT::StatefulActionNode(name, config) {}
template<typename PortType>
[[nodiscard]] bool processPort(PortType & port_value, const std::string & port_name);
};
} // namespace bt_deliberation_examples
The companion bt_node_bases.cpp provides the definition and forces explicit instantiations for every type used across the package:
#include "bt_deliberation_examples/bt_node_bases.hpp"
// bt_type_converters.hpp must be included here so that convertFromString<PortType>
// specialisations are visible when the explicit instantiations below are compiled.
// The explicit instantiation of processPort<T> forces the full getInput<T> call chain
// (which is inline in BT.CPP v3's tree_node.h) to be compiled in this TU, ensuring
// the correct specialisation is used rather than the primary template which throws.
#include "bt_deliberation_examples/bt_type_converters.hpp"
#include <geometry_msgs/msg/pose_stamped.hpp>
#include <moveit_msgs/msg/robot_state.hpp>
#include <moveit_msgs/msg/robot_trajectory.hpp>
namespace bt_deliberation_examples
{
template <typename PortType>
bool BtExamplesSyncActionNode::processPort(PortType & port_value, const std::string & port_name)
{
auto res = getInput(port_name, port_value);
return static_cast<bool>(res);
}
template <typename PortType>
bool BtExamplesStatefulActionNode::processPort(PortType & port_value, const std::string & port_name)
{
auto res = getInput(port_name, port_value);
return static_cast<bool>(res);
}
// Explicit instantiations
template bool BtExamplesSyncActionNode::processPort<std::string>(
std::string &, const std::string &);
template bool BtExamplesSyncActionNode::processPort<double>(
double &, const std::string &);
template bool BtExamplesSyncActionNode::processPort<bool>(
bool &, const std::string &);
template bool BtExamplesSyncActionNode::processPort<int>(
int &, const std::string &);
template bool BtExamplesSyncActionNode::processPort<geometry_msgs::msg::PoseStamped>(
geometry_msgs::msg::PoseStamped &, const std::string &);
template bool BtExamplesSyncActionNode::processPort<moveit_msgs::msg::RobotState>(
moveit_msgs::msg::RobotState &, const std::string &);
template bool BtExamplesSyncActionNode::processPort<moveit_msgs::msg::RobotTrajectory>(
moveit_msgs::msg::RobotTrajectory &, const std::string &);
template bool BtExamplesStatefulActionNode::processPort<std::string>(
std::string &, const std::string &);
template bool BtExamplesStatefulActionNode::processPort<double>(
double &, const std::string &);
template bool BtExamplesStatefulActionNode::processPort<bool>(
bool &, const std::string &);
template bool BtExamplesStatefulActionNode::processPort<int>(
int &, const std::string &);
template bool BtExamplesStatefulActionNode::processPort<geometry_msgs::msg::PoseStamped>(
geometry_msgs::msg::PoseStamped &, const std::string &);
template bool BtExamplesStatefulActionNode::processPort<moveit_msgs::msg::RobotState>(
moveit_msgs::msg::RobotState &, const std::string &);
template bool BtExamplesStatefulActionNode::processPort<moveit_msgs::msg::RobotTrajectory>(
moveit_msgs::msg::RobotTrajectory &, const std::string &);
} // namespace bt_deliberation_examples
Diagnostics: Tracking Failure Causes
When a Fallback fires, the FAILURE that triggered it typically bubbles up from a leaf somewhere deep in the tree — invisible to higher-level composites by default.
The WhoFailedAction pattern surfaces it: a FailureHistoryLogger observes every status-change event across the whole tree; whenever a leaf transitions to FAILURE, it records the leaf’s name and registration ID into a shared FailureRecorder. A WhoFailedAction node, placed as the final child of a high-level Fallback, reads the recorder on tick and writes the root cause to output ports — giving the Fallback a named cause to log or route on.
The recorder is reset at the start of each root tick so a stale failure from a previous cycle cannot be misattributed.
Figure 12: WhoFailedAction data flow. The FailureHistoryLogger observes all nodes; on any leaf FAILURE it writes to the shared recorder. WhoFailedAction reads it at the tail of the Fallback.
#pragma once
#include <memory>
#include <mutex>
#include <optional>
#include <string>
#include <behaviortree_cpp_v3/loggers/abstract_logger.h>
#include <behaviortree_cpp_v3/tree_node.h>
namespace bt_deliberation_examples
{
/// Identity of the leaf (Action/Condition) node behind the most recent
/// FAILURE seen while ticking the tree.
struct LeafFailure
{
std::string name; // node.name(), e.g. "move_to_pick"
std::string registration_id; // node.registrationName(), e.g. "MoveItGotoNamed"
};
/**
* Thread-safe holder for "the most recent leaf FAILURE seen anywhere in the
* tree this tick". Construct one instance, share it between:
* - a FailureHistoryLogger attached to the tree (writes), and
* - any WhoFailedAction nodes placed at the tail of high-level Fallbacks (reads).
*
* Call reset() once at the start of every root tick (see bt_runner.cpp) so a
* stale failure from a previous tick can't be misreported as the cause of a
* Fallback that didn't actually fail this time.
*/
class FailureRecorder
{
public:
void record(const std::string& name, const std::string& registration_id)
{
std::lock_guard<std::mutex> lock(mutex_);
last_failure_ = LeafFailure{name, registration_id};
}
void reset()
{
std::lock_guard<std::mutex> lock(mutex_);
last_failure_.reset();
}
std::optional<LeafFailure> last() const
{
std::lock_guard<std::mutex> lock(mutex_);
return last_failure_;
}
private:
mutable std::mutex mutex_;
std::optional<LeafFailure> last_failure_;
};
/**
* BT.CPP v3 StatusChangeLogger that forwards every leaf FAILURE transition
* into a shared FailureRecorder, ignoring transitions on composites
* (Sequence/Fallback/...), decorators and SubTree nodes -- only true
* Action/Condition leaves are recorded.
*
* Because <SubTree>/<include> are flattened into real TreeNode instances at
* factory.createTreeFromFile() time, this sees failures inside subtrees just
* as well as failures at the top level: there's no "subtree wall" to special
* case here.
*
* Construct this *after* the tree exists (it needs tree.rootNode()), unlike
* FailureRecorder which can be created up front and threaded through
* registerNodes().
*/
class FailureHistoryLogger : public BT::StatusChangeLogger
{
public:
FailureHistoryLogger(BT::TreeNode * root_node, std::shared_ptr<FailureRecorder> recorder)
: BT::StatusChangeLogger(root_node), recorder_(std::move(recorder)) {}
void callback(BT::Duration /*timestamp*/, const BT::TreeNode& node,
BT::NodeStatus /*prev*/, BT::NodeStatus next) override
{
if (next != BT::NodeStatus::FAILURE) {
return;
}
if (node.type() != BT::NodeType::ACTION && node.type() != BT::NodeType::CONDITION) {
return;
}
recorder_->record(node.name(), node.registrationName());
}
void flush() override {}
private:
std::shared_ptr<FailureRecorder> recorder_;
};
} // namespace bt_deliberation_examples
In your runner…
#include "bt_deliberation_examples/failure_history.hpp"
// Attach after createTreeFromFile(). Records the most recent leaf FAILURE
// seen anywhere in the tree each tick -- read by WhoFailedAction nodes.
// Call failure_recorder->reset() at the top of each tick loop iteration.
bt_deliberation_examples::FailureHistoryLogger failure_history_logger(tree.rootNode(), failure_recorder);
#pragma once
#include <string>
#include <behaviortree_cpp_v3/action_node.h>
#include "bt_deliberation_examples/failure_history.hpp"
namespace bt_deliberation_examples
{
/**
* Diagnostic leaf meant to sit as the last child of a high-level Fallback.
*
* When ticked (i.e. every earlier sibling in the Fallback has already
* failed), it reports the most recent leaf (Action/Condition) FAILURE
* recorded anywhere in the tree this tick -- including inside subtrees and
* past any number of nested Sequence/Fallback composites in between -- which
* is the actual root cause that triggered this Fallback, not just "the
* immediate child failed".
*
* Always returns SUCCESS so it doesn't itself break the enclosing Fallback;
* it exists purely to surface what went wrong, e.g. so a sibling can log it
* or feed it into PublishLog.
*
* Ports:
* failed_leaf : output string -- name() of the leaf that actually failed
* failed_leaf_type : output string -- its registrationName() (e.g. "MoveItGotoNamed")
*/
class WhoFailedAction : public BT::SyncActionNode
{
public:
WhoFailedAction(const std::string& name, const BT::NodeConfiguration& config,
std::shared_ptr<FailureRecorder> recorder)
: BT::SyncActionNode(name, config), recorder_(std::move(recorder)) {}
static BT::PortsList providedPorts()
{
return {
BT::OutputPort<std::string>("failed_leaf", "name() of the leaf that actually failed"),
BT::OutputPort<std::string>("failed_leaf_type", "registrationName() of that leaf"),
};
}
BT::NodeStatus tick() override
{
auto failure = recorder_->last();
setOutput("failed_leaf", failure ? failure->name : std::string("<unknown>"));
setOutput("failed_leaf_type", failure ? failure->registration_id : std::string("<unknown>"));
return BT::NodeStatus::SUCCESS;
}
private:
std::shared_ptr<FailureRecorder> recorder_;
};
} // namespace bt_deliberation_examples
Resources and Further Reading
Hey you!
Found this useful or interesting?
Consider donating to support.
Any question, comments, corrections or suggestions?
Reach out on the social links below through the buttons.