Skip to main content
This guide walks you through creating a minimal working Unit that subscribes to a topic, processes a message, and publishes a response. You’ll write a .unit.yaml declaration, run the code generator, implement your handler, and run the Unit against the coordinator.
Complete this guide inside the Basis Docker environment or after sourcing /opt/basis/bash/source_env.sh. See Environment Setup if you haven’t done that yet.

What you’re building

You’ll create a greeter Unit with a single handler called OnPing. It subscribes to /ping (a TimeTest protobuf message), logs the received timestamp, and publishes a forwarded copy to /pong. The TimeTest message is defined in the Basis example protobuf and has a single double time field — it’s a simple, self-contained type you can use immediately without writing any .proto files.

Project layout

Create a directory for your unit:
mkdir -p ~/greeter/{include,src,template}
cd ~/greeter
By the end of this guide your directory will look like this:
greeter/
├── greeter.unit.yaml       # handler declaration (you write this)
├── CMakeLists.txt          # build file (you write this)
├── include/
│   └── greeter.h           # generated Unit header (do not edit)
├── src/
│   └── greeter.cpp         # generated handler stubs (you edit this)
├── template/
│   ├── greeter.example.h   # reference template (do not edit)
│   └── greeter.example.cpp # reference template (do not edit)
└── generated/              # all other generated files (do not edit)

Step-by-step

1

Write the unit YAML file

Create greeter.unit.yaml in the ~/greeter directory:
greeter.unit.yaml
threading_model: single

cpp_includes:
  - basis_example.pb.h

handlers:
  OnPing:
    sync:
      type: all
    inputs:
      /ping:
        type: "protobuf: ::TimeTest"
    outputs:
      /pong:
        type: "protobuf: ::TimeTest"
Breaking down the key fields:
  • threading_model: single — all handlers on this unit run mutually exclusive from each other. This is the only supported threading model today.
  • cpp_includes — headers that the generated code needs to resolve the message types. basis_example.pb.h defines TimeTest.
  • handlers.OnPing — the name of the handler function you will implement.
  • sync.type: all — the handler fires once all required inputs have a message queued. For a single input this means “fire whenever a /ping message arrives”.
  • inputs./ping — the topic name and its message type. The protobuf: prefix selects the protobuf serializer. ::TimeTest is the fully-qualified C++ type name (the leading :: puts it in the global namespace).
  • outputs./pong — the topic and type of the value your handler must return.
The type format is serializer:CppType. Supported serializers are protobuf, rosmsg (requires BASIS_ENABLE_ROS=ON), and raw. See Unit YAML schema for the full reference.
2

Run code generation

The code generator reads your .unit.yaml and produces a C++ base class, handler structs, and stub implementation files.
python3 /basis/python/unit/generate_unit.py \
  greeter.unit.yaml \
  generated \
  .
Arguments:
  1. greeter.unit.yaml — path to the unit definition file
  2. generated — output directory for generated base files
  3. . — source directory where include/, src/, and template/ live
The generator only writes include/greeter.h and src/greeter.cpp if they do not already exist. Subsequent runs update all generated files under generated/ but never overwrite your handler implementation.
After running, inspect what was generated:
ls include/ src/ template/ generated/unit/greeter/
You should see:
  • include/greeter.h — your Unit class declaration (edit this)
  • src/greeter.cpp — your handler implementations (edit this)
  • template/greeter.example.h and template/greeter.example.cpp — reference copies, regenerated each time
  • generated/unit/greeter/unit_base.h, unit_base.cpp, create_unit.cpp — the generated base class (do not edit)
3

Review the generated header

Open include/greeter.h. It was generated once and is yours to edit:
include/greeter.h
/*
  This is the starting point for your Unit. Edit this directly and implement the missing methods!
*/
#include <unit/greeter/unit_base.h>

class greeter : public unit::greeter::Base {
public:
  greeter(const Args& args, const std::optional<std::string_view>& name_override = {})
  : unit::greeter::Base(args, name_override)
  {}

  virtual unit::greeter::OnPing::Output
  OnPing(const unit::greeter::OnPing::Input &input) override;
};
The generated Base class handles all publisher and subscriber setup. Your job is to implement OnPing.The Input and Output structs are also generated, in generated/unit/greeter/handlers/OnPing.h:
// Input — one field per declared input topic
struct Input {
  basis::core::MonotonicTime time;
  std::shared_ptr<const TimeTest> ping;  // from /ping
};

// Output — one field per declared output topic
struct Output {
  std::shared_ptr<const TimeTest> pong;  // for /pong
};
Topic names are converted to valid C++ identifiers: /ping becomes ping, /my_topic becomes my_topic.
4

Implement the handler

Open src/greeter.cpp. Replace the generated stub with a real implementation:
src/greeter.cpp
#include <greeter.h>
#include <spdlog/spdlog.h>

using namespace unit::greeter;

OnPing::Output greeter::OnPing(const OnPing::Input& input) {
    // Log the timestamp from the incoming message
    spdlog::info("Received ping with time: {:.6f}s", input.ping->time());

    // Forward the message to /pong unchanged
    return OnPing::Output{
        .pong = input.ping,
    };
}
A few things to note:
  • input.ping is a std::shared_ptr<const TimeTest>. Access proto fields via ->.
  • Return an Output struct with each declared output field populated. Non-optional outputs that are left nullptr will produce an error log at runtime.
  • Zero-copy forwarding (returning the same shared pointer from input to output) is safe and efficient when using inproc transport.
5

Write a CMakeLists.txt

Create CMakeLists.txt in the ~/greeter directory:
CMakeLists.txt
cmake_minimum_required(VERSION 3.25.1)
project(greeter)

list(APPEND CMAKE_MODULE_PATH "/basis/cmake")

find_package(basis REQUIRED PATHS /opt/basis)

include(Unit)

generate_unit(greeter
  DEPENDS
    basis::plugins::serialization::protobuf
    basis::plugins::transport::tcp
)

target_include_directories(unit_greeter PRIVATE /opt/basis/include)
The generate_unit CMake function (from cmake/Unit.cmake) wires up the code generator as a build step and creates two targets:
CMake targetDescription
unit_greeterShared library (greeter.unit.so) — loadable by the coordinator
unit_greeter_binStandalone executable (greeter) — runs the unit directly
DEPENDS lists any additional libraries your unit needs to link against. For protobuf messages you always need basis::plugins::serialization::protobuf. Add basis::plugins::serialization::rosmsg if you use ROS message types.
6

Build the unit

Configure and build from the ~/greeter directory:
cmake -S . -B build \
  -DCMAKE_BUILD_TYPE=Debug \
  -DBASIS_SOURCE_ROOT=/basis
cmake --build build --parallel
On success, the build outputs:
  • build/greeter.unit.so — the loadable unit shared library
  • build/greeter — the standalone unit executable
7

Start the coordinator and run the unit

Basis requires a coordinator process to be running before any Unit can connect. The coordinator manages topic routing and transport negotiation between Units.Open a first terminal and start the coordinator:
coordinator
Open a second terminal and run the unit:
source /opt/basis/bash/source_env.sh
./build/greeter
The standalone unit binary calls WaitForCoordinatorConnection(), CreateTransportManager(), Initialize(), and then loops calling Update(). This is the same pattern used in basis_example.cpp:
int main(int argc, char* argv[]) {
    basis::core::logging::InitializeLoggingSystem();

    greeter unit;
    unit.WaitForCoordinatorConnection();
    unit.CreateTransportManager();
    unit.Initialize();

    while (true) {
        unit.Update(nullptr, basis::core::Duration::FromSecondsNanoseconds(1, 0));
    }
}
The generated unit_greeter_bin target produces this main() automatically — you do not need to write it by hand when using generate_unit. The standalone binary is generated from create_unit.cpp.j2.
If the coordinator is not running, WaitForCoordinatorConnection() retries every second and logs:
[warn] No connection to the coordinator, waiting 1 second and trying again

What happens at runtime

When you call Update(), Basis:
  1. Drains any pending transport-level messages (connects new subscribers/publishers as reported by the coordinator).
  2. Pops queued subscriber callbacks from the overall_queue.
  3. For each queued callback, checks whether the synchronizer for the matching handler is satisfied.
  4. If satisfied, calls your handler function with the collected inputs.
  5. Publishes each non-null field from the returned Output struct to the corresponding topic.
Because threading_model is single, all handlers are serialized through a single queue — no locks required in your handler code.

Complete example: the basis_example unit

For a richer reference, the basis_example binary in the Basis repository demonstrates:
  • A 1 Hz RateSubscriber publishing TimeTest messages
  • A 10 Hz RateSubscriber publishing ExampleStampedVector messages
  • A topic subscriber (OnTimeTest) that receives published messages and forwards them
  • Conditional compilation for ROS message types (sensor_msgs::PointCloud2)
  • An approximate-timestamp synchronizer combining two topics
The entry point follows the same WaitForCoordinatorConnectionCreateTransportManagerInitializeUpdate loop:
int main(int argc, char* argv[]) {
    basis::core::logging::InitializeLoggingSystem();

    ExampleUnit example_unit;
    example_unit.WaitForCoordinatorConnection();
    example_unit.CreateTransportManager();
    example_unit.Initialize();

    while (true) {
        example_unit.Update(nullptr, basis::core::Duration::FromSecondsNanoseconds(1, 0));
    }
}
Build and run it from inside the Docker environment:
cmake -S /basis -B /basis/build -DCMAKE_BUILD_TYPE=Debug
cmake --build /basis/build --target basis_example --parallel

# In one terminal
coordinator

# In another terminal
/basis/build/cpp/examples/basis_example

Next steps

Unit YAML schema

Full reference for all handler, sync, input, and output options

Code generation

Understand how generate_unit.py works and what files it produces

Synchronizers

Learn about all, equal, approximate, and rate sync strategies

Transport

Inproc, TCP, and how to control which transports a topic uses

Build docs developers (and LLMs) love