A lightweight framework for real‑time robotics or monitoring and control of industrial processes — built from small, composable agents that talk over a ZeroMQ message bus.
MADS lets you build a data pipeline out of independent processes — agents — that you can start, stop, move between machines, and rewire without touching each other. Each agent does one job (read a sensor, transform a stream, log to a database, drive an actuator) and exchanges JSON messages over a publish/subscribe bus. A central broker handles discovery and hands every agent the same configuration, so the whole fleet stays consistent even when it spans several computers.
You extend MADS the way that fits you:
- 🧩 Drop‑in plugins — write a tiny
.pluginthat turns one JSON object into another; the runtime handles all the networking. - 🛠️ Native C++ agents — subclass
Mads::Agentand letAgentApptake care of CLI parsing, settings, startup and shutdown. - 🐍 Python — drive an agent end‑to‑end from a script via the ctypes wrapper.
📖 Full guides, tutorials and API docs live at https://mads-net.github.io.
- Composable — pipelines are wired in a config file, not in code.
- Distributed by default — agents find the broker automatically; settings are served centrally.
- Polyglot & portable — C++ core, C ABI, Python wrapper; Linux, macOS and Windows.
- Fast — ZeroMQ transport, Snappy compression, an opt‑in MessagePack wire format, and a zero‑copy receive fast path.
- Batteries included — ready‑made agents for logging to MongoDB, bridging, feedback/inspection, and more.
- Secure — optional CURVE encryption and IP allow‑listing on every socket.
%%{init: {"flowchart": {"defaultRenderer": "elk"}} }%%
flowchart LR
field([Field / Sensors])
broker{{"Broker (pub/sub + settings)"}}
src["Source"]
flt["Filter"]
snk["Sink"]
log["Logger"]
db[(MongoDB)]
field --> src
src -- publish --> broker
broker -- subscribe --> flt
flt -- publish --> broker
broker -- subscribe --> snk
broker -- subscribe --> log
snk --> field
log --> db
- Source — produces data and publishes it.
- Filter — subscribes, transforms, republishes.
- Sink — subscribes and consumes locally (UI, logging, bridging, actuation…).
- Broker — a payload‑agnostic ZeroMQ
XSUB/XPUBproxy that also serves settings over aREQ/REPsocket and advertises itself for auto‑discovery.
Agents come in two flavours: monolithic executables (one purpose‑built binary) and plugin‑based ones (a generic loader — mads‑source, mads‑filter, mads‑sink — that loads a .plugin at runtime).
# Build & install (see COMPILE.md for prerequisites)
cmake -Bbuild -DCMAKE_BUILD_TYPE=Release -GNinja
cmake --build build -j6
cmake --install buildRun your first pipeline — a data source feeding a live console sink:
mads broker & # 1. start the hub (always first)
mads feedback & # 2. a sink that pretty-prints every message
mads perf_assess -l 64 -p 500 # 3. a source emitting a 64-byte payload every 500 msYou’ll see feedback print each message the source publishes. Swap step 3 for your own source, add filters in between, point logger at MongoDB — all by editing mads.ini, not the binaries.
mads <name>is a convenience launcher for themads-<name>executables.mads -pprints the install prefix;mads pluginscaffolds a new plugin project.
Every message is a pair [topic, payload]. Your code always works with a nlohmann::json object (or a Python dict); MADS handles the bytes on the wire.
flowchart LR
p["Producer (nlohmann::json)"] -->|encode| h["frame: topic · header · payload"]
h --> b{{"Broker (opaque — never parses payloads)"}}
b -->|decode| c["Consumer (nlohmann::json)"]
Because the broker never looks inside payloads, the wire format can evolve with zero broker changes.
| Default | Opt‑in | |
|---|---|---|
| Encoding | JSON (human‑readable, universal) | MessagePack (compact binary) |
| Compression | auto (Snappy above 256 B) |
snappy (always) · none |
| Framing | legacy 2/3‑part frame | self‑describing header {format, compression, schema_version} |
Switch formats per agent or fleet‑wide in mads.ini — no recompilation:
[agents] # fleet-wide default
wire_format = "msgpack" # "json" (default) | "msgpack"
compression = "auto" # "auto" (default) | "snappy" | "none"
[my_fast_source] # …or override per agent
wire_format = "msgpack"
compression = "none" # already compact; skip Snappy on a LANIt’s also reachable from C (agent_set_wire_format) and Python (set_wire_format). Mixed fleets interoperate: every up‑to‑date receiver transparently decodes both formats thanks to the self‑describing header.
The right choice depends on your payload and your consumer. From the bundled benchmarks (Apple M‑series, release build):
- Encoding is much cheaper in MessagePack — JSON has to scan and escape every byte; MessagePack bulk‑copies. For a 16 KB payload, encoding is ~30× faster.
- Wire size shrinks for numeric/structured data; for already‑compressible text the gap narrows once Snappy kicks in.
- Receiving is fastest when your consumer wants the object: the agent keeps the decoded message and hands it over without re‑serializing.
To make that last point real, consumers can pull the parsed object directly with last_json() instead of re‑parsing text. On a MessagePack stream this more than doubled sink throughput:
| Payload | last_message() + parse |
last_json() (fast path) |
Speed‑up |
|---|---|---|---|
| 1 KB | 45,400 msg/s | 92,700 msg/s | 2.0× |
| 16 KB | 5,200 msg/s | 14,300 msg/s | 2.8× |
Rule of thumb & methodology
- Use JSON for control/observability topics, debugging, and interop with external tools — it’s readable on the wire.
- Use MessagePack for high‑rate or numeric/array‑heavy telemetry, especially when the consumer processes the object (filters, sinks) rather than re‑printing it as text. Pair it with
compression = "none"on fast local links. - A sink that re‑stringifies every message (e.g. printing it) is text‑bound and won’t benefit from MessagePack — and the framework still gives it correct JSON either way.
Benchmarks use the bundled perf_assess source and feedback/compute sinks through a real broker. Numbers are indicative, not guarantees — measure with your payload. The transport (Snappy + ZeroMQ + network) usually dominates the codec.
A plugin is a small shared library that transforms JSON. It knows nothing about ZeroMQ, the broker, or settings plumbing — the loader injects all of that. Three kinds map to the three agent roles:
flowchart LR
subgraph L["mads-source / -filter / -sink (loader)"]
direction TB
plug["your .plugin"]
end
broker{{Broker}}
L <-->|JSON in / JSON out| broker
| Plugin kind | You implement | Loaded by |
|---|---|---|
| Source | get_output(json &out) |
mads-source |
| Filter | load_data(json in) + process(json &out) |
mads-filter |
| Sink | load_data(json in) |
mads-sink |
mads plugin my_filter # scaffold a ready-to-build plugin project
# edit src/plugin/my_filter.cpp, then:
cmake -Bbuild -GNinja && cmake --build build
mads filter my_filter.plugin # run itPlugins are developed and versioned in their own repos, compiled independently, and selected (with their settings section) by file name — or by -n <name> to run the same plugin in several roles with different configs.
For full control, subclass Mads::Agent and wrap it with AgentApp (src/agent_app.hpp), which folds away the repetitive CLI/startup boilerplate (option parsing, settings, identity, queue sizing, events, remote control, graceful restart):
#include "agent_app.hpp"
using namespace Mads;
int main(int argc, char *argv[]) {
AgentApp agent{argv[0], SETTINGS_URI};
agent.add_common_options();
auto parsed = agent.parse_options(argc, argv);
if (int rc = AgentApp::handle_standard_exit_options<AgentApp>(
parsed, agent.raw_options(), argv); rc >= 0)
return rc;
agent.init(parsed);
agent.enable_events();
agent.connect();
agent.info();
agent.loop([&]() -> std::chrono::milliseconds {
if (agent.receive() == message_type::json) {
auto [topic, msg] = agent.last_json(); // parsed object, zero re-parse
// …process msg, then…
agent.publish({{"result", 42}});
}
return 0ms;
});
agent.disconnect();
agent.restart_if_requested(argv);
}Existing subclasses (Bridge, Dealer, Worker, Logger, …) can be wrapped without modification via AgentAppFor<T>.
Drive an agent from Python through the ctypes wrapper (installed at <prefix>/python/mads_agent.py):
from mads_agent import Agent, WireFormat, MessageType
agent = Agent("my_agent", "tcp://localhost:9092") # settings URI = broker
agent.init()
agent.set_wire_format(WireFormat.MSGPACK) # optional
agent.connect()
agent.publish({"temperature": 21.7, "unit": "C"})
if agent.receive() == MessageType.JSON:
topic, message = agent.last_message()
print(topic, message)Point it at a specific library with the MADS_LIB_PATH environment variable, or let it resolve via mads -p.
Settings live in a single TOML file (mads.ini). The broker reads it first and serves it to every agent over the settings socket, so a distributed fleet shares one source of truth.
- One
[section]per agent, named after the executable (mads-logger→[logger]) or the plugin file (clock.plugin→[clock], or[custom]with-n custom). - A shared
[agents]section holds fleet‑wide defaults (endpoints, timecode FPS, wire format…). - Common keys:
pub_topic(string),sub_topic(array;[""]= subscribe to all),queue_size,time_step.
[agents]
frontend_address = "tcp://localhost:9090"
backend_address = "tcp://localhost:9091"
[logger]
mongo_uri = "mongodb://localhost:27017"
mongo_db = "mads"
sub_topic = [""] # log everythingThe logger agent persists every message to MongoDB (and/or a plain JSON file for debugging). Each message becomes a document in a collection named after its topic:
| field | meaning |
|---|---|
_id |
document id (MongoDB) |
timestamp |
BSON date when logged |
message |
the payload, as a BSON document (extended‑JSON $date fields become real BSON dates) |
error |
set instead of message if the payload was unparsable |
Spin up MongoDB quickly with Docker:
docker run --name mads-mongo --restart unless-stopped \
-v ${PWD}/db:/data/db -p 27017:27017 -d mongoSee COMPILE.md for prerequisites and platform notes. In short: CMake (out‑of‑source), C++20, Ninja recommended.
cmake -Bbuild -DCMAKE_BUILD_TYPE=Release -GNinja
cmake --build build -j6
cmake --install buildKey dependencies: ZeroMQ (libzmq + zmqpp), nlohmann/json, toml++, Snappy, pugg (plugins), and the MongoDB C++ driver (optional, for the logger).
All these dependencies are bundled and built from source via CMake FetchContent, so no external library installation is needed.
- C++20; LLVM style enforced with
clang-format. - Classes & namespaces
CamelCase; functions & variablessnake_case; member fields_leading_underscore. - Headers
.hpp, sources.cpp. Library code insrc/; executables (withmain()) insrc/main/. - Prefer extending existing abstractions (
Mads::Agent,AgentApp, plugin drivers) over re‑implementing transport/config plumbing.
Distributed under the Creative Commons BY‑SA 4.0 license.
Paolo Bosetti (University of Trento) — main author. Contributors: Anna‑Carla Araújo (INSA Toulouse), Guillaume Cohen (INSA Toulouse).
