Getting Started with the Ambiorix (amx) Bus
This article introduces the Ambiorix (amx) framework used in prplOS, and walks through building a small data model component from scratch, on top of ubus.
prplOS is the open-source, OpenWrt-based reference distribution maintained by the prpl Foundation for building broadband CPE and Wi-Fi devices. It layers TR-181/USP-oriented data models, mesh networking, and lifecycle-management packages on top of a standard OpenWrt base, assembled from a handful of feeds — an Ambiorix feed, a prplOS packages feed, a mesh feed, and a chip-vendor feed for whichever SoC you’re targeting. The Ambiorix (amx) framework is what prplOS uses to describe and expose those data models — and it’s what people mean when they say the "amx bus".
Since prplOS is built on OpenWrt, it inherits ubus as its underlying
IPC transport. In our earlier article,
Getting
Started with OpenWrt Micro Bus (ubus), we introduced ubus — OpenWrt’s
lightweight IPC daemon (ubusd) that lets services register objects
and methods, with clients calling them over JSON — and showed how
rpcd lets you plug a shell script in as a ubus object, using a small
sys_time example with current.time and current.date methods.
Where ubus and rpcd Fall Short
That works well for small, flat services. But the moment you need a
hierarchical data model — objects containing objects, multi-instance
lists, parameters with validation, events, TR-181/USP style trees — hand-rolling all of that in a shell script or in raw libubus C code
gets painful fast. ubus and rpcd don’t give you, out of the box:
-
a hierarchical tree of objects and sub-objects,
-
generic
get/set/add/deletesemantics on every object, -
parameter validation, defaults, read-only/persistent attributes,
-
events, multi-instance ("template") objects, or a data-model description format you can validate before running.
You can build all of that yourself on top of libubus. This is the
problem the Ambiorix framework (often just called "amx", and what
people mean when they say the "amx bus") solves instead: describe
your data model declaratively, and let the framework generate the
plumbing, including the ubus registration. It is the framework prplOS
uses to build and expose Broadband Forum style data models, while
still letting the underlying transport be plain old ubus.
This is a beginner’s introduction to Ambiorix: what it actually is,
how its pieces fit together, and how to write and publish a small
data model component from scratch — the amx equivalent of the
sys_time rpcd example above.
What Is Ambiorix?
Ambiorix is a set of C libraries plus a small runtime, built around one core idea: your service should never call bus-specific APIs directly. Instead, you talk to a bus-agnostic API, and a pluggable backend translates that onto whatever bus is actually running — ubus, or another bus system entirely — without your code changing.
The pieces that matter for a beginner:
| Component | Role |
|---|---|
|
Common C containers/collections (variants, lists, hash tables) — the "missing STL" for C |
|
Utility patterns: timers, signal handling, process control |
|
The data model manager — hierarchical objects, parameters, functions, events, transactions |
|
Parses ODL (Object Definition Language) files and builds a
|
|
The bus-agnostic API — this is "the amx bus" |
|
A |
|
The runtime: parses your ODL, loads your compiled logic, connects to a bus backend, and runs the event loop |
Amx Bus Architecture
Components never talk to a bus API directly. They talk to libamxb,
and whichever backend plugin is loaded translates that onto the real
bus underneath — ubus or otherwise, without the component code
changing.
Compare that to the rpcd approach: there, you wrote the code that
speaks the list/call JSON protocol for a single, specific bus.
Here, the backend plugin and amxrt write that code for you — you
only describe what the object looks like (in ODL) and implement the
custom logic (in C), and it works unchanged on any bus that has a
backend.
Anatomy of an Ambiorix Component
A typical amx component has two halves:
-
ODL file(s) — declarative description of the data model: objects, parameters, methods, defaults, config.
-
A shared object (
.so) — C implementations of any custom RPC methods or action callbacks (validation, read hooks, etc.) referenced from the ODL.
amxrt ties them together at runtime: it parses the ODL, builds the
data model in memory via libamxd, dlopen's your .so and resolves
function names against it, connects to whatever bus backend is
configured/available, and registers the resulting objects on the bus.
ODL basics
ODL files have (up to) three sections:
-
%config { ... }— runtime settings: component name, which files to load, bus options, storage paths. -
%define { ... }— the actual data model shape: objects, parameters, methods, events. -
%populate { ... }— wiring: event subscriptions, initial instances, etc.
A minimal object definition looks like this:
%define {
object SysTime {
string currentTime();
string currentDate();
}
}
-
object SysTime { }declares a singleton object. A multi-instance ("template") object would be writtenobject SysTime[] { }. -
string currentTime();declares a method that returns a string and takes no arguments. -
Method arguments use attributes like
%in,%out,%mandatory,%strict(e.g.string say(%in %mandatory string message)), similar in spirit to%in/%outUSP parameter direction.
Function resolution
For every method in the ODL, Ambiorix looks for a matching C function,
by default named _<method> (or _<Object>_<method> if there are
name clashes) inside the .so you import. The expected C prototype
is fixed:
amxd_status_t _currentTime(amxd_object_t* object,
amxd_function_t* func,
amxc_var_t* args,
amxc_var_t* ret);
-
object— the data model object the method was called on. -
func— metadata about the method itself. -
args— anamxc_var_t(a generic variant container) holding the input arguments. -
ret— anamxc_var_tyou fill in with the return value. -
The return value of the C function itself is an
amxd_status_t(amxd_status_ok,amxd_status_invalid_arg, etc.) — the protocol status, separate from the result you place inret.
That’s the entire contract. No JSON parsing, no list/call dispatch
loop to write.
Hands-On: Building a "SysTime" Component
Let’s build the amx equivalent of the classic rpcd sys_time example:
an object with two methods, currentTime and currentDate.
1. Directory layout
systime/
├── systime.odl
├── systime.c
└── Makefile
2. The ODL file (systime.odl)
#!/usr/bin/amxrt
%config {
name = "systime";
}
import "${name}.so" as "${name}";
%define {
object SysTime {
string currentTime();
string currentDate();
}
}
-
The shebang lets you run the file directly once it’s executable (
amxrtis a real interpreter for.odlfiles, just like#!/bin/shfor shell scripts). -
import "${name}.so" as "${name}";tellsamxrttodlopensystime.soso it can resolve_currentTime/_currentDate. -
The object definition is inlined here for simplicity; larger components typically split this into a separate
systime_definition.odlincluded viainclude.
3. The implementation (systime.c)
#include <time.h>
#include <amxc/amxc.h>
#include <amxp/amxp.h>
#include <amxd/amxd_object.h>
#include <amxd/amxd_function.h>
amxd_status_t _currentTime(amxd_object_t* object,
amxd_function_t* func,
amxc_var_t* args,
amxc_var_t* ret) {
(void) object;
(void) func;
(void) args;
time_t now = time(NULL);
struct tm tm_info;
char buf[9];
localtime_r(&now, &tm_info);
strftime(buf, sizeof(buf), "%H:%M:%S", &tm_info);
amxc_var_set(cstring_t, ret, buf);
return amxd_status_ok;
}
amxd_status_t _currentDate(amxd_object_t* object,
amxd_function_t* func,
amxc_var_t* args,
amxc_var_t* ret) {
(void) object;
(void) func;
(void) args;
time_t now = time(NULL);
struct tm tm_info;
char buf[11];
localtime_r(&now, &tm_info);
strftime(buf, sizeof(buf), "%Y-%m-%d", &tm_info);
amxc_var_set(cstring_t, ret, buf);
return amxd_status_ok;
}
amxc_var_set(cstring_t, ret, buf) stores a C string into the generic
variant that gets sent back over the bus — the amx equivalent of
writing a JSON string into the rpcd reply.
Note : <amxd/amxd_object.h> uses types (like amxp_signal_mngr_t)
that come from <amxp/amxp.h>, but doesn’t include it for you — and <amxp/amxp.h> in turn expects <amxc/amxc.h> to already be
visible. Leave any of the three out, or reorder them, and you’ll get
"unknown type name" errors deep in a header you didn’t write. Include
amxc before amxp before amxd, every time.
4. The Makefile
CROSS_COMPILE ?=
SYSROOT ?=
CC = $(CROSS_COMPILE)gcc
ifeq ($(SYSROOT),)
CFLAGS += -Wall -fPIC $(shell pkg-config --cflags amxc amxd)
LDFLAGS += -shared $(shell pkg-config --libs amxc amxd)
else
CFLAGS += -Wall -fPIC --sysroot=$(SYSROOT) -I$(SYSROOT)/usr/include
LDFLAGS += -shared --sysroot=$(SYSROOT) -L$(SYSROOT)/usr/lib -lamxc -lamxd
endif
TARGET = systime.so
all: $(TARGET)
$(TARGET): systime.c
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
clean:
rm -f $(TARGET) *.o
install: $(TARGET)
install -D -m 0644 $(TARGET) $(DESTDIR)/usr/lib/amx/systime/$(TARGET)
install -D -m 0644 systime.odl $(DESTDIR)/etc/amx/systime/systime.odl
.PHONY: all clean install
Build it natively if the Ambiorix dev libraries and headers are installed on your machine:
root@prplOS:~/systime# make
root@prplOS:~/systime# sudo -E make install
Cross-Compiling for the Target
If you’re developing on your workstation but the device is, say, an
aarch64 board like the Banana Pi BPI-R4, you don’t build natively — you point the Makefile at your prplOS/OpenWrt SDK’s cross toolchain
and the target’s staging sysroot (which already has libamxc,
libamxd, and their headers built for that architecture):
$ make \
CROSS_COMPILE=<sdk>/staging_dir/toolchain-aarch64_cortex-a53_gcc-13.3.0_musl/bin/aarch64-openwrt-linux- \
SYSROOT=<sdk>/staging_dir/target-aarch64_cortex-a53_musl
CROSS_COMPILE is the compiler prefix (the Makefile appends gcc),
and SYSROOT is the target’s staging directory — the same one the
buildroot uses when it compiles every other package for this board.
Confirm the result before shipping it over:
$ file systime.so
systime.so: ELF 64-bit LSB shared object, ARM aarch64, version 1 (SYSV), dynamically linked, not stripped
$ <sdk>/staging_dir/toolchain-.../bin/aarch64-openwrt-linux-strip systime.so
Then copy the two files the component needs straight onto the board and start it exactly as before:
$ scp systime.so systime.odl root@<bpi-r4-ip>:/tmp/
root@prplOS:~# mkdir -p /usr/lib/amx/systime /etc/amx/systime
root@prplOS:~# mv /tmp/systime.so /usr/lib/amx/systime/
root@prplOS:~# mv /tmp/systime.odl /etc/amx/systime/
root@prplOS:~# amxrt /etc/amx/systime/systime.odl -D
From here, ubus list / ubus call and ba-cli work against the
real board exactly as described below — the ODL and the RPC contract
don’t change based on which CPU is executing them, only the compiler
flags used to build the .so did.
5. Running it
Make sure ubusd is running, and a ubus backend is available to
amxrt (this is the amxb_ubus plugin — on prplOS/OpenWrt it’s
already part of the base image). Then:
root@prplOS:~/systime# chmod a+x systime.odl
root@prplOS:~/systime# ./systime.odl -D
-D runs it as a daemon. Under the hood, amxrt parsed the ODL,
built the SysTime object in memory, dlopen'd systime.so,
connected to ubusd through amxb_ubus, and registered SysTime as
a ubus object — all without you writing a single line of ubus
registration code.
RPC Daemon vs amxrt Architecture
Where rpcd sits between ubusd and a shell plugin speaking
list/call JSON, amxb_ubus sits between ubusd and amxrt,
speaking the bus-agnostic protocol that libamxd understands.
6. Verifying with ubus
Because the backend is ubus, all your familiar tools still work:
root@prplOS:~/systime# ubus list
SysTime
root@prplOS:~/systime# ubus -v list SysTime
'SysTime' @...
"currentTime":{}
"currentDate":{}
"_list":{"parameters":"Boolean","functions":"Boolean","objects":"Boolean","instances":"Boolean","template_info":"Boolean","events":"Boolean","access":"(unknown)","rel_path":"String"}
"_describe":{"parameters":"Boolean","functions":"Boolean","objects":"Boolean","instances":"Boolean","exists":"Boolean","access":"(unknown)","rel_path":"String","events":"Boolean"}
"_get":{"rel_path":"String","parameters":"Array","depth":"Integer","access":"(unknown)","filter":"String"}
"_get_instances":{"rel_path":"String","depth":"Integer","access":"(unknown)"}
"_get_supported":{"first_level_only":"Boolean","parameters":"Boolean","functions":"Boolean","events":"Boolean","rel_path":"String","access":"(unknown)"}
"_set":{"parameters":"Table","oparameters":"Table","access":"(unknown)","rel_path":"String","allow_partial":"Boolean"}
"_add":{"parameters":"Table","index":"(unknown)","name":"String","access":"(unknown)","rel_path":"String"}
"_del":{"index":"(unknown)","name":"String","access":"(unknown)","rel_path":"String"}
"_exec":{"method":"String","args":"Table","rel_path":"String"}
root@prplOS:~/systime# ubus call SysTime currentTime
{
"retval": "14:32:10"
}
{
}
{
"amxd-error-code": 0
}
root@prplOS:~/systime# ubus call SysTime currentDate
{
"retval": "2026-07-30"
}
{
}
{
"amxd-error-code": 0
}
Each call prints three separate JSON objects rather than one flat
reply: the return value under the generic key retval, an empty
table for named output arguments (currentTime()/currentDate()
only declare a return type, not out args), and a status object
carrying amxd-error-code — the same status _currentTime() /
_currentDate() returned from C, where 0 means amxd_status_ok.
It’s not an error; it’s amxb_ubus always appending the call’s
result code after the return value.
Notice _list, _describe, _get, _get_instances,
_get_supported, _set, _add, _del, _exec showed up
automatically — you didn’t implement any of those. Every object
published by Ambiorix
gets generic parameter get/set and instance add/delete semantics for
free, on top of the two custom methods you wrote.
7. Verifying with ba-cli
ubus is a fine way to check your component, but it only speaks
ubus. prplOS also ships ba-cli — the "Bus Agnostic Command Line
Interface" — which talks to whatever backend libamxb has loaded
(ubus among them) through the same bus-agnostic API your component is
built on, instead of a bus-specific one.
root@prplOS:~# ba-cli
Bus Agnostic CLI
Add config options for backends
Load Ambiorix Bus Agnostic CLI
Load all available back-ends
Open connectings ...
Set mode cli
Disable ACL verification by default
Define some aliases
Reset history
- * - [bus-cli] (0)
>
List the object and call its methods exactly as you would in an
interactive shell — methods, not just parameters, need () to be
invoked:
> ls -rnpf SysTime.
SysTime.
SysTime.currentTime
SysTime.currentDate
- * - [bus-cli] (0)
> SysTime.currentTime()
SysTime.currentTime() returned
[
"14:32:10"
]
- * - [bus-cli] (0)
> SysTime.currentDate()
SysTime.currentDate() returned
[
"2026-07-30"
]
Forgetting the () calls out the mistake instead of silently doing
nothing, and the trailing (0)/(-1) in the prompt tracks the last
command’s status:
> SysTime.currentDate
ERROR: Missing or invalid operator
- * - [bus-cli] (-1)
Same component, same data model, same result you got from
ubus call — just reached through the bus-agnostic front door
instead of a bus-specific one. That’s the practical payoff of
building on libamxb: one component, testable and usable from any
backend that’s loaded, with no code changes.
Comparing the Two Approaches
| rpcd plugin | Ambiorix component | |
|---|---|---|
Data model shape |
Flat, one object per script |
Hierarchical objects, multi-instance templates, parameters |
Registration on ubus |
You call |
Generated automatically from your ODL by |
Get/set/add/delete |
You implement per method, if at all |
Provided automatically for every object |
Portability to another bus |
None — tied to ubus |
Swap the backend plugin for a different bus — your ODL/C stays the same |
What you write |
Full |
ODL description + small C functions matching a fixed prototype |
Under the Hood
If you run ubus monitor in one terminal while calling
ubus call SysTime currentTime in another, you’ll see the same
low-level ubus message exchange you’d see for any other object — connection to ubusd, method invocation, JSON reply. Ambiorix doesn’t
replace ubus; amxb_ubus is just a very thorough, generic
rpcd-like client that happens to be data-model-aware, driven entirely
by what your ODL declares instead of by code you hand-write per
method.
That’s the core mental model: ODL describes the shape, libamxd holds
the live tree in memory, your C functions supply custom behavior, and
libamxb/amxb_ubus project all of it onto ubus so existing tools
(ubus list, ubus call, ubus monitor) keep working unchanged.
Conclusion
Hope this article gives an introduction to the Ambiorix (amx) bus architecture and creating your own custom data model component on top of ubus. In the next article, we will explore multi-instance ("template") objects, parameter validation, and events.