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/delete semantics 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

libamxc

Common C containers/collections (variants, lists, hash tables) —  the "missing STL" for C

libamxp

Utility patterns: timers, signal handling, process control

libamxd

The data model manager — hierarchical objects, parameters, functions, events, transactions

libamxo

Parses ODL (Object Definition Language) files and builds a libamxd data model from them

libamxb

The bus-agnostic API — this is "the amx bus"

amxb_ubus

A libamxb backend plugin that maps the bus-agnostic API onto real ubus calls

amxrt

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.

/static/images/amx-bus-service-development/amx-bus-architecture.png

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:

  1. ODL file(s) — declarative description of the data model: objects, parameters, methods, defaults, config.

  2. 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 written object 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/%out USP 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 — an amxc_var_t (a generic variant container) holding the input arguments.

  • ret — an amxc_var_t you 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 in ret.

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 (amxrt is a real interpreter for .odl files, just like #!/bin/sh for shell scripts).

  • import "${name}.so" as "${name}"; tells amxrt to dlopen systime.so so it can resolve _currentTime / _currentDate.

  • The object definition is inlined here for simplicity; larger components typically split this into a separate systime_definition.odl included via include.

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

/static/images/amx-bus-service-development/amxrt-ubus-interaction.png

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 ubus_add_object © or emit list JSON yourself (script)

Generated automatically from your ODL by amxrt + amxb_ubus

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 list/call dispatch + JSON encode/decode

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.

Talk To Us