📄 fleetless.yaml
One file configures one robot: the values it exposes, the actions and services it offers, the topics clients may send to, the cameras it streams.
The file says nothing about the robot’s identity — no name, no id, no organisation. A file downloaded from one robot uploads unchanged onto another. Comments and key order survive the round trip, which is why this is a file rather than four forms.
fleetless: 1
datapoints:
battery_soc:
topic: /battery
type: sensor_msgs/msg/BatteryState
field: percentage
description: Main battery charge.
numeric:
scale: 100
unit: "%"
publishers:
drive:
topic: /cmd_vel
type: geometry_msgs/msg/Twist
description: Velocity command. If sending stops, the robot stops.
message:
linear: { x: "${linear_speed}", y: 0.0, z: 0.0 }
angular: { x: 0.0, y: 0.0, z: 0.0 }
parameters:
linear_speed:
type: float64
min_value: -0.5
max_value: 0.5
failsafe:
timeout_ms: 500
message:
linear: { x: 0.0, y: 0.0, z: 0.0 }
angular: { x: 0.0, y: 0.0, z: 0.0 }
quiet_timeout_ms: 2000
That is a complete document. Every other key in the format is optional or lives under one of these.
Sections
| section | what it holds |
|---|---|
datapoints |
Values the robot publishes: one field of one topic, or a whole topic. |
alerts |
A state machine on one datapoint’s value, nested under it. |
messages, parameters |
The message template a call sends, and the holes a caller fills. |
actions, services |
Things the robot does on request, long-running and short. |
publishers |
Topics clients may send to, with the failsafe that stops them. |
cameras |
Video the robot streams, and the stills the cloud serves from it. |
low_bandwidth |
Rate and camera limits the bridge applies while the link is bad. |
The header
fleetless: 1
Required, an integer, and conventionally the first line. It decides how everything below is read, so a file that omits it — or names a version this cloud does not know — is refused rather than half understood.
The key is deliberately not version. The console counts published states as
v12 to v13, and two numbers both called version would be the likeliest
confusion the format could offer.
Names
Slugs, parameter names and shared message names share one grammar:
^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$
Lowercase, underscore-joined, letter-initial, 2 to 63 characters. Valid:
battery_soc, dock, linear_speed. Refused: Battery, 1st_cam,
battery-soc, battery__soc, dock_.
Slugs are one namespace across the five exposure sections — datapoints,
actions, services, publishers and cameras. A datapoint and a camera
cannot share a slug, which is what lets a role grant name {robot, slug}
without ever naming a kind. Shared message names are their own namespace, and
a parameter name is scoped to the entry that declares it.
Three slugs are reserved, and naming one in any of the five sections is
refused as reserved_slug:
| slug | why |
|---|---|
bridge_state |
Built in on every robot: whether the bridge is connected, and its latency when it is. |
robot_details |
Built in: the static properties a developer maintains about the robot. |
history |
GET /api/robots/:id/jobs/history is a real route, so a job slug spelled history would sit at the same address. |
A slug is stable and decoupled from ROS names. Renaming a topic on the robot changes one line here and breaks no client. Changing a slug breaks every client, every role grant and every MCP call that named it.
ROS names, ROS types and field paths keep ROS’s own spelling — they are not ours to restyle.
Two rules
Omission is the only way to say “not set”. An explicit null is refused,
at any depth of a message body as much as at a top-level key. One spelling,
not two.
An unknown key is an error. The refusal is unknown_key, one issue per
key, so it names the offending key rather than its parent. A typo in a field
name that is silently ignored is the most expensive kind of silence.
Save vs. publish
A draft is stored whenever the text parses as YAML, whatever it says. Text that is not this format at all is kept with no document behind it, so a half-written file is never lost. The editor lists the findings as you type, and publish is what enforces them.
| refusal | code | where |
|---|---|---|
| The text is not YAML at all | invalid_yaml |
save, 422 |
| The text is YAML that cannot be stored, such as an anchor cycle | unstorable_yaml |
save, 422 |
| The draft is YAML but not a configuration | draft_not_a_document |
publish, 422 |
| The document has blocking findings | validation_error |
publish, 422 |
The line and column of a syntax error travel in the refusal’s details, so
an editor can put a marker on the offending line rather than merely declining.
A slug rename asked of a draft that is not a document answers
draft_not_a_document as well, there as a 409.
Not every finding blocks. A topic the last introspection did not see, or a type it has never resolved, is a warning: the robot may simply not have connected yet.
What reaches the robot
The robot is sent the document unmutated — every key, including every one in the right-hand column. The table is about where a field has its effect, never about what is transmitted.
| the robot acts on it | only the cloud acts on it |
|---|---|
topic, type, field, rate_throttle_hz |
retention.enabled, retention.interval_seconds |
numeric.scale, numeric.offset |
numeric.unit, numeric.decimals |
retention.max_buffer_values |
a datapoint’s description |
actions, services, publishers, messages |
chart, alerts |
| cameras, credentials included |
A published version reaches the bridge only when something in the left column changed. Otherwise a corrected alert threshold would push a configuration to a robot, which is churn for nothing on a link that is often a phone connection. The comparison is a projection of the robot-affecting fields, so a key the format grows later is cloud-only until somebody says otherwise.
The split inside numeric is worth reading twice. scale and offset are
arithmetic the robot performs before sending, so changing them changes what
the robot does. unit and decimals are labels the cloud puts on the result.
Descriptions split along the same line and not the way you would guess. A
datapoint’s description is cloud-only, but an action, service, publisher or
camera is compared whole, so rewording one of those descriptions does push a
new configuration.
Alerts and charts therefore take effect only on publish. That is the price of one door, and in exchange every change to them is versioned, comparable and revertible.
Limits
| what | maximum |
|---|---|
datapoints, actions, services, publishers, messages |
200 entries each |
cameras |
50 entries |
parameters per entry |
50 |
| a slug, parameter name or message name | 63 characters |
a description |
2000 characters |
a parameter description |
500 characters |
numeric.unit |
32 characters |
an alert name |
120 characters |
| a ROS name, ROS type name or field path | 255 characters |
a camera url |
2048 characters |
a camera device |
128 characters |
a credential username or password |
128 characters |