An app usually needs data that is not a robot’s: the places it drives to, the parts it inspects, what the operator wrote down. Collections keep that data in Fleetless, so the app needs no backend of its own for it.
A collection is a named set of records with typed fields. The app reads and writes them through REST, the SDK or MCP, and every change is kept.
What collections are for
- Master data. Locations with their bounds, chargers with a dock position, thousands of components searched by name.
- Reference lists. The categories and options a form offers.
- Records users write. A logbook entry per robot, a maintenance note, a setting an operator may change.
Collections belong to one app and are never shared between apps. They are open on every plan, with no feature gate; Plans and Limits counts their records and bytes.
They are not telemetry and not file storage. Both are covered in the patterns below.
The schema document
One YAML document per app holds all its collections. It lives the way a robot’s
fleetless.yaml does: draft → publish →
versions → restore. Records have no draft; they are live the moment they are
written.
format: 1
collections:
locations:
label: Locations
binding: global
display: "{{name}}"
fields:
name: { type: text, label: Name, required: true, unique: true, searchable: true, max_length: 80 }
tunnel_from_m: { type: number, unit: m }
tunnel_to_m: { type: number, unit: m }
components:
label: Components
binding: global
display: "{{name}} ({{code}})"
access:
read: [observe, operate]
create: operate
update: operate
delete: operate
fields:
code: { type: text, required: true, unique: true, searchable: true }
name: { type: text, required: true, searchable: true }
kind: { type: select, options: [{ value: quadrupole, label: Quadrupole }, { value: bpm, label: BPM }] }
location: { type: reference, to: locations, on_delete: prevent }
position_m: { type: number, unit: m }
approach: { type: json, schema: pose2d }
robot_settings:
label: Robot settings
binding: one_per_robot
display: "{{robot.name}}"
access:
read: [observe, operate]
update: operate
fields:
max_linear_speed: { type: number, unit: m/s, min: 0, max: 1.5, required: true, default: 0.8 }
home: { type: reference, to: locations, on_delete: clear }
logbook:
label: Logbook
binding: per_robot
display: "{{created_at}}"
access:
read: [observe, operate]
create: operate
update: { all: operate }
delete: { all: operate }
fields:
text: { type: long_text, required: true, searchable: true }
category: { type: select, options: [{ value: note }, { value: fault }, { value: maintenance }] }
- Keys are the API. A collection key and a field key match
^[a-z][a-z0-9_]{0,62}$and appear in URLs, filters and record bodies.labelanddescriptionare free text for people and may change at any time. - Reserved field keys:
id,robot,robot_id,created_at,created_by,updated_at,updated_byandrevision. - Field order is the order of the record form in the console.
locationshas noaccess. Only developers and server keys reach its records, which is what “nobody by default” means.displayis a template that names a record in lists, references and titles. It may use the collection’s own fields, its system fields,robot.nameon a collection that belongs to robots, and one hop through a reference:{{location.name}}.
A document is invalid, and refused with 400 validation_error naming the
path, only when the document itself is wrong. Nothing about the stored data
makes it invalid; the data’s fate is previewed and confirmed instead, see
Publishing a schema. The schema document
reference lists every key, the field types’
options and the findings that make a document invalid.
The schema is for developers: the console and the console’s MCP tools. No role of an app user and no server key can be given it. The console edits it as YAML only, and records in a side panel; developers mostly let an assistant write the YAML, see Use it from an assistant.
Bindings
A collection says how it relates to robots. That is a property of the collection, not a field type, so a record’s robot can neither be forgotten nor changed.
| Binding | Records | System field |
|---|---|---|
global |
Belong to the app and to no robot. | none |
per_robot |
Each belongs to one robot. | robot_id, set on create and fixed afterwards |
one_per_robot |
Every robot of the app has exactly one, which Fleetless creates. | robot_id |
- Robot-bound records exist only for robots attached to the app. Detaching a robot hides its records from every caller, developers and server keys included; attaching it again shows them again, and detaching from one app touches no other.
- Fleetless creates the
one_per_robotrecord, with the actorfleetless, when a robot is attached and has none, and when the collection is published. A record kept while the robot was detached is shown again, not replaced. - The API never creates or deletes a
one_per_robotrecord. It reads and updates it, andgetby robot always finds one. - A required field of a
one_per_robotcollection needs adefault. Fleetless creates the record itself and cannot ask anyone to fill it. - Deleting a robot deletes its records in every app, with their history, at once. Deleting an app deletes all its collections the same way.
The same robot has independent records in each app that uses it.
Field types
Every field has a type. Every type takes label, description, required,
unique and default; the table lists what each adds.
| Type | Holds | Options of its own |
|---|---|---|
text |
One line of text | max_length (default 255, at most 1,000), pattern (RE2 syntax), searchable |
long_text |
Text with line breaks | max_length (default 10,000, at most 100,000), searchable |
number |
A JSON number | integer, min, max, unit (shown, never converted) |
boolean |
true or false |
none |
select |
One option value, or a list of them | options (value and an optional label, at most 500), multiple, searchable (matches values and labels) |
date |
YYYY-MM-DD |
none |
datetime |
ISO 8601 in UTC, millisecond precision | none |
json |
Any JSON value | schema: a template name or an inline JSON Schema (draft 2020-12) |
reference |
The id of a record in another collection | to, on_delete |
- Empty is absent. A field without a value has no key in the record, and a
write that sends
nullclears the field. unique: truemeans unique within the collection (global) or within one robot (per_robot,one_per_robot).unique: collectionon a robot-bound collection means across all its robots. Empty values never collide, andbooleanandjsonfields cannot be unique.defaultmust itself be a valid value of the field. A new record gets it when it does not set the field.patternis matched anywhere in the value; anchor it with^and$to match all of it. Lookahead, lookbehind and backreferences are not part of the syntax, and a pattern the cloud judges too complex is refused when the draft is saved.
JSON fields
A json field names one of four templates, or carries an inline JSON Schema.
The templates use metres and radians, and frame_id is a string; each has an
example in the reference.
| Template | Shape |
|---|---|
pose2d |
{ x, y, yaw, frame_id } |
point2d |
{ x, y, frame_id } |
polygon2d |
{ points: [{ x, y }], frame_id }, at least three points |
range1d |
{ from, to } |
An inline schema has limits, so that one value cannot cost the server a second:
- A
patternin it needs amaxLengthof at most 10,000 in the same schema object. The cloud may refuse a pattern as too complex there as well. uniqueItemson items that are not plain scalars needs amaxItemsof at most 100.- Object keys inside a value are at most 1,000 characters.
References
A reference field holds the id of a record in the collection named by to.
The record must exist when the reference is written, and between two
robot-bound collections it must belong to the same robot.
| From | To | Allowed |
|---|---|---|
per_robot, one_per_robot |
global |
yes |
per_robot, one_per_robot |
per_robot, one_per_robot |
yes, to a record of the same robot |
global |
per_robot, one_per_robot |
never; the document is invalid |
on_delete says what happens when the target record is deleted through the
record API or an import:
prevent(the default) refuses the delete with409 record_referenced, naming the referencing collections and how many records each has.cleardeletes it and clears the reference in each record that pointed at it, one revision per record.
on_delete never governs a schema publish. Removing a collection removes its
records, and the references to them are cleared.
An app user who sets a reference must be allowed to read the target
collection, or the write is refused with reference_invalid.
Who may do what
Rights are written into the schema, per collection, as access:
access:
read: [observe, operate]
create: operate
update: { all: lead, own: operate }
delete: lead
Here lead stands for a role you created in your organisation; observe and
operate are two that a new organisation starts with.
- A value is a role key or a list of role keys, the keys of the
organisation’s roles.
A role also holds what it inherits, so a right given to
operateis held byadmintoo, and with a list any one role is enough.updateanddeletealso take{ all, own }:allfor every record,ownfor records whosecreated_byis the caller. - Nobody by default. A missing key, or a collection without
access, grants that action to nobody. The console marks such a collection “No role has access”. - Developers and the app’s server keys may do everything with records.
- An unknown role key is a warning, not an error. It grants nothing and shows at preview, at publish and in the console. Renaming or deleting a role therefore never breaks a published schema.
- A
one_per_robotcollection takesreadandupdateonly.createanddeletethere are a warning and grant nothing. - Rights take effect at publish. A changed right closes the realtime subscriptions it no longer covers.
For a collection that belongs to robots there is one more filter, in front of the rights. Only the records of robots attached to the app are reached at all, by developers and server keys too, and an app user also needs a role that reaches the robot: at least one slug or capability of it, once the app’s locks are applied (see Roles and Rights). Every read, write, subscription and history read goes through the same check.
A caller with none of the four rights on a collection gets
404 collection_not_found, as for a collection that does not exist. A caller
with some right, but not this one, gets 403 forbidden.
Reading and writing records
Records are read with list and get. A list takes these, all optional:
| Parameter | What it does |
|---|---|
filter |
{ "<field>": { "<op>": value } }, combined with { "and": [...] } and { "or": [...] }. Operators: eq, ne, lt, lte, gt, gte, contains (case-insensitive; text, long text and select), in (at most 100 values) and empty. |
search |
Words separated by whitespace, at most 10. Every word must occur, in any letter case, in at least one searchable field. |
sort |
Up to 3 fields, a leading - for descending. id is the last tie-break. |
robot |
Only the records of one robot, on a robot-bound collection. |
limit, cursor |
50 records by default, at most 500. The cursor is opaque and stays valid while records are written. |
count |
true adds total, the number of matches. |
expand |
Reference fields to embed as records, one hop. |
deleted |
true lists deleted records instead. Needs the right to create. |
- A filter goes one hop through a reference:
{ "location.name": { "eq": "XTD1" } }. The system fieldsid,robot_id,created_at,updated_atandcreated_bycan be filtered and sorted on. A filter nests at most 3 levels and holds at most 50 conditions. - Sorting is refused on
long_text, onjsonand on aselectwithmultiple: they have no order that fits in a page cursor. - Page until
next_cursorisnull, not until a page looks short. - An embedded record appears only when the caller may read its collection; otherwise the field keeps the id.
A record on the wire is an envelope: id, robot_id, created_at,
created_by, updated_at, updated_by, revision, display, values, and
missing_required when it has any. The actors are { kind, id, label }, with
the kinds developer, app_user, server_key and fleetless.
Writes follow these rules:
- Updates are partial. Fields left out keep their value;
nullclears one. required: a create sets every required field, and an update may not clear one. A record whose required fields went empty after a schema change stays updatable in its other fields, and every response names the empty ones inmissing_required. A write that breaks this answers400 required_missingwith the fields.expected_revisionanswers409 record_changed, with the current revision, when the record moved on since you read it.- A unique value that is taken answers
400 validation_error. - A record is at most 1 MiB as serialized JSON, and a value may not contain the NUL character (U+0000).
The refusals specific to collections are listed under Collection errors.
Publishing a schema
A developer saves the whole document as the draft, which is validated on the way in. A preview then computes what publishing would do to the data as it is now, and returns a confirmation token over exactly that.
- Without effects, publish succeeds at once. With effects it needs the
token of the current preview. A missing or stale token answers
409 confirmation_requiredwith the current preview and its token. - Data that changed between preview and publish yields a new token. The caller confirms again.
- Publish is one transaction: the new version, every effect, every revision
and the audit entry. Afterwards the realtime event
collections_schema_publishedreaches every subscriber of the app. - The preview also lists, as information, every change of
accessand every warning. Neither needs a confirmation.
Collections and fields are matched by key only. There is no rename detection, so a changed key is a removal plus an addition. Each effect is computed per collection and field:
| Change | Effect |
|---|---|
| Collection removed | Its records are deleted; references to them in other collections are cleared. |
| Binding changed | Treated as removal and re-creation: all its records are deleted. |
Collection added, one_per_robot |
One record per attached robot is created. |
| Field removed | Its values are removed from all records. |
| Field added | Records get the field’s default, if it has one. |
| Type changed | Values are converted (table below); those that cannot be are discarded. |
| Reference target changed | All values of the field are discarded. |
| Select option removed | Values using it are discarded; for multiple, only that element. |
max_length, pattern, min, max, integer or JSON schema tightened |
Values that no longer satisfy the field are discarded. |
unique added or widened |
Among duplicates the record created first keeps its value, then the lowest id; the others’ values are discarded. The preview lists up to 1,000 of them, each with its id, display text and value, and states the exact total; the token covers all of them. |
required added |
Empty values get the default when there is one. Otherwise they stay empty, and the preview counts them per field. |
Every changed value is written as a revision with the actor fleetless and the
reason schema v<N>. An effect that would change nothing is not listed.
A type change converts like this, and the target’s constraints apply afterwards:
| From → to | Rule |
|---|---|
text ↔ long_text |
Copied, then max_length applies. |
text, long_text → number |
A trimmed decimal with .; anything else is discarded. |
number → text, long_text |
The shortest decimal. |
text → boolean |
true, false, yes, no, 1, 0, case-insensitive; else discarded. boolean → text is true or false. |
number ↔ boolean |
0 and 1 only. |
text → select |
Kept when it equals an option value. select → text is the value, joined with , when multiple. |
select single → multiple |
[value]. multiple → single is kept when exactly one element is left. |
text → date, datetime |
ISO 8601, else discarded. date → datetime is 00:00:00Z; datetime → date is the UTC date; both → text is ISO 8601. |
any → json |
The JSON value itself. json → other is converted as from the value’s own kind, else discarded. |
anything ↔ reference |
Discarded. |
Restoring a version
Every publish creates a numbered version with its document, author, time and a summary of its effects. Documents are kept for the life of the app.
Restore version N publishes N’s document as a new version. With
data: true it also puts the records back to their state at N’s restore
point, the moment immediately before the version that replaced N was
published:
- values change back, records deleted since come back, and records created since are deleted;
- only in the collections the schema difference touches. A logbook whose schema did not change keeps every record;
- the preview names the touched collections and counts what changes, and the
caller may deselect any of them with
collections; - the changes are reverted by new revisions. They stay in the history and can be restored one by one.
Restoring with data is possible while the restore point is at most 90 days
old. Older versions, and restores with data: false, put the document back
only, with the ordinary effects of a publish. Records of robots deleted since
cannot come back, and the preview counts them as such.
A restore is confirmed with a token like a publish, and it is a new version, so it can be undone the same way.
History
Every change to a record is a revision: the operation (create, update,
delete, restore), the actor, the time, the reason (schema v5, import,
restore v4, empty for an ordinary write), each changed field with its old and
new value, and on a delete the whole record.
- It is append-only. Users and developers cannot remove revisions. Fleetless’s own changes are revisions too.
- Whoever may update a record may read its history and restore a revision. Restoring a deleted record, and listing deleted records, need the right to create.
- Revisions are kept while the record exists and after it is deleted. They go with the robot or the app, 90 days after a publish removed or re-created their collection, or by an owner’s purge.
- An owner may purge the revisions older than a chosen date, for one collection or the whole app. Current values are untouched, the preview names the restore points that stop working, and the history of every affected record then starts with a note saying who purged what and when. A purge is an owner function in the console and is not in MCP.
- A user who no longer exists is labelled
Deleted user. - A history that grows fast is flagged when a collection holds more than 10 times as many revisions as records and at least 10,000. It usually means a client writing in a loop.
The history is not counted against the plan limits, but its size is shown.
Import and export
Developers import and export, in the console, over the console’s MCP tools and with a server key. The role of an app user cannot.
An import takes a CSV (UTF-8, a header row, RFC 4180; a json field as
JSON text) or a JSON array of objects, at most 10 MB or 50,000 rows.
- Mapping. Each source column goes to a field, or to “do not import”. A
reference column maps to a unique field of the target, a robot-bound
collection takes a
robotcolumn (name or id) or one target robot. - Key. With a unique field as key, existing records are updated and the rest
created. Without one, every row is created. A
one_per_robotcollection only updates, keyed by robot. - Mode
mirrorneeds a key and also deletes the records missing from the file, within a scope you name (a filter or a robot). Without a scope it covers the whole collection, and the preview says so. - All or nothing. One invalid row, an unresolvable reference, a limit
overrun or a mirror delete blocked by a
preventreference fails the whole import. The preview lists the errors with row, column, value and reason. - Preview, then confirm. Every import starts as a preview, also a file that
only creates records: without the token nothing is imported. The preview
counts unchanged, new, updated and deleted records and errors, and returns a
token. The import, called again with that token, runs in one
transaction, writes revisions with the reason
import, one audit entry, and counts as one write for the rate limit.
An export writes CSV or JSON with the current filter, search and sort and a chosen set of fields. References are written as an id or as a unique field of the target, so the file can be imported again.
Realtime
An app follows a collection over the realtime connection it already has.
collection_subscribe takes the collection and, optionally, a robot and a
filter. The SDK wraps it as collections.subscribe.
| Event | Means |
|---|---|
created, updated, deleted |
The record was written. |
removed |
The record left the subscriber’s view: its values stopped matching, the subscriber’s rights changed, or the robot was detached. |
entered |
The record came into view the same way. |
- Every event is checked against the subscriber’s current rights and filter, deletes included, and carries the full record.
- There is no first snapshot, but the cloud confirms a subscription. Once it
has accepted and registered one, it sends
collection_subscribedwith therequest_id, before any event of that subscription. Wait for it, then list: every change after the confirmation reaches you as an event, so none falls between the list and the events. An event can arrive while the list is still being read and may or may not be in its result; a record’srevisiontells a newer state from an older one. - A refusal is
collection_subscribe_error, with the codes the REST API uses, and nocollection_subscribed. A collection that does not exist, or is not readable, is refused. - The same frame ends a running subscription:
collection_removed,forbidden,robot_not_attached,validation_errorwhen its filter no longer fits the collection, andinternal_errorwhen the cloud could not judge the subscription again. Nothing more arrives after that; subscribe again to follow the collection. - A subscribe frame is at most 16,384 bytes. A larger one closes the
connection with code
1009instead of being answered, so a filter with many long values may not fit. The SDK refuses such a subscription withinvalid_optionbefore it sends it. - A developer’s connection names the app with
app_id. An app user’s or a server key’s may leave it out, and a value it sends must be its own app. collections_schema_publishedwith the new version reaches every subscriber of the app. A subscription to a collection that no longer exists ends withcollection_subscribe_errorand the codecollection_removed.- Re-roling an app user closes their subscriptions, and so does a right that no longer covers them.
- A connection that falls too far behind is closed with code
1013, try again later: more than 2,000 changes wait to be delivered, or 8 MiB are unsent. The SDK connects again and subscribes again, and changes made in the meantime are not replayed: the new subscription is confirmed with a newcollection_subscribed, and the SDK’sonReadycallback is called again, so the same list in the same place refreshes what the app holds.
Limits and rates
The plan limits count current records and their bytes, across all apps of the organisation.
| Basic | Plus | Pro | Enterprise | |
|---|---|---|---|---|
| Collection records | 25,000 | 250,000 | 1,000,000 | by contract |
| Collection storage | 1 GB | 10 GB | 50 GB | by contract |
- Over a limit, a write or an import that would add data is refused with
409 plan_limitnamingcollection_recordsorcollection_bytes. Only growth is refused: an update that shrinks a record, and a delete, always go through, so an organisation over its limit can work its way back under it. Reads continue, and the import preview warns beforehand. - What Fleetless does itself is never refused: attaching a robot, or
publishing and restoring a schema that creates
one_per_robotrecords or fills defaults. Those records count, and the limit applies at the next user write. - The write rate is 600 writes a minute per app and 120 a minute per actor.
An import counts as one. Over it, the answer is
429 rate_limitedwithdetails.retry_after_msand aRetry-Afterheader.
These caps are the same on every plan:
| Cap | Value |
|---|---|
| Collections per app | 50 |
| Fields per collection | 100 |
Options of a select |
500 |
| One record, as serialized JSON | 1 MiB |
| The schema document | 1,000,000 bytes of UTF-8 |
| Filter | 3 levels, 50 conditions, 100 values in in |
| Words in a search | 10 |
| Fields in a sort | 3 |
| Import | 10 MB or 50,000 rows |
Use it from an assistant
A developer’s AI tool writes the schema and edits records through the console
endpoint, and an app user’s tool reaches the records the role allows. See
MCP for the two endpoints and docs.describe for the exact
parameters of an action.
| Tool | Actions |
|---|---|
console_collections_read |
list, draft_get, preview, versions, version_get |
console_collections_write |
draft_put, draft_discard, publish, restore |
console_collection_records_read |
list, get, history, export |
console_collection_records_write |
create, update, delete, restore, import |
collections_read |
definitions, list, get, history |
collections_write |
create, update, delete, restore |
- The first four are developer tools.
draft_puttakes the whole YAML and answers with the validation errors or the preview. - The last two are app tools, listed in every app session, with the rights
decided per call: an action the role lacks answers
403 forbidden. - Every import takes two calls, and a publish or a restore takes two when it changes stored data. The first answers the preview and its token, the second confirms.
- The purge is not in MCP.
- MCP for app users needs the Plus plan or above. On Basic, app users reach collections through the app and the SDK only.
Use it from an app
The SDK has a collections namespace on the client. It reads and writes as the
signed-in user, or with a server key.
// What this caller may read, with the fields and what they may do with each,
// and the schema version they belong to.
const { version, collections } = await client.collections.definitions()
// A subscription delivers changes, not a snapshot. `onReady` is called when the cloud has
// confirmed it: list there, and every change after that reaches the callback. After a
// dropped connection nothing is replayed, but `onReady` is called again. The SDK does not
// handle what `onReady` returns, so a failing `list` is caught here.
const read = async () => {
try {
const { records } = await client.collections.list('logbook', { robot: robotId, sort: ['-created_at'], limit: 20 })
console.log(`${records.length} entries`)
} catch (error) {
console.error('Could not read the logbook', error)
}
}
const unsubscribe = client.collections.subscribe('logbook', { robot: robotId, onReady: read }, (change) => {
console.log(change.event, change.id, change.record?.display)
})
// Search and filter run in the cloud.
const parts = await client.collections.list('components', { search: 'quad xtd1', filter: { position_m: { gte: 2 } }, sort: ['position_m'] })
const part = await client.collections.get('components', parts.records[0].id, { expand: ['location'] })
// Write. A `one_per_robot` collection has exactly one record per robot.
await client.collections.create('logbook', { text: 'Replaced the left wheel' }, { robot: robotId })
const settings = await client.collections.get('robot_settings', { robot: robotId })
await client.collections.update('robot_settings', settings.id, { max_linear_speed: 0.6 }, { expectedRevision: settings.revision })
unsubscribe()
- A list resolves
{ records, nextCursor, total? }. - Errors are the SDK’s typed errors with the REST codes. A caller branches
on
error.code, andrecordChangedDetails.parse(error.details)reads the revision arecord_changedrefusal found. expandongetembeds reference fields the way it does onlist.
Four patterns follow. Each is a way to use collections that works for a week.
Keep telemetry out of collections
A collection is for records that people or an app write now and then. A measurement that arrives every second is a datapoint, which has a rate, retention and a history built for it.
Collections are the wrong place for it for three reasons:
- every write is a revision that is kept, so a record updated every second gains 86,400 revisions a day and trips the growth warning;
- the write rate is 600 a minute per app and 120 per actor, which one writer at 10 Hz spends in twelve seconds;
- records stored as a series fill the plan’s record limit.
If the app needs the last known value of something, read the datapoint. A
setting an operator edits, such as a speed limit the app enforces, is a record,
and fits a one_per_robot collection.
Records without a robot belong in a global collection
A per_robot record exists only while its robot does. It is hidden when the
robot is detached from the app, and deleted with the robot in every app.
Master data that outlives or spans robots, such as locations, chargers and
parts, belongs in a global collection. A per_robot collection can point at
it with a reference. The other direction is not allowed: a global collection
cannot reference a record that belongs to a robot.
Binary data belongs in assets
A record is at most 1 MiB of JSON and there is no file field. Do not put an
image or a document in a long_text or json field as base64.
The files Fleetless stores are assets: a robot’s URDF, meshes and textures, described under URDF & meshes. Any other file lives in your own storage, and the record holds its URL or its key.
Renaming is removal plus addition
The schema matches collections and fields by key, so a renamed key is a
removal and an addition. Publishing it deletes the old field’s values and
leaves the new field empty, or at its default. The preview shows exactly
that, which is how you notice.
Move the values first. There are two ways:
- Copy by export and import. Publish the new field next to the old one. Export the collection with its unique field and the old field, and import the file with the old column mapped to the new field and the unique field as key. Then ship the app that reads the new field, and publish without the old one.
- Switch the app over between two publishes. Publish the new field, ship
an app version that writes both fields and reads the new one, falling back
to the old. Copy the remaining records with a one-off
listandupdate, and publish without the old field once nothing reads it.
Renaming a collection works the same way: publish the new collection, copy the records into it, switch the app, and remove the old one. Records of a removed collection are purged 90 days after the publish, and a restore with data brings them back until then.