If you've configured connectors before, you'll remember place mappings: a hierarchical mapping table where you matched source-system rows to a Site → Building → Floor → Space → Thing tree, with a separate "provision" step and template-based mapping rules. Entities replaces all of that.
What changed, in customer terms:
| Old way (place mappings) | New way (Entities) |
|---|---|
| A nested Site→Building→Floor→Space tree you filled in | A flat table (Grid), one screen per kind of thing |
| Separate "provision" step, then "map" step | One screen, one flow — Import, then Save |
| Mapping a Space to a Floor was a separate column/target | The Space simply has a Floor cell you pick |
| Rules written as text templates | Rules written as a small TypeScript function (optional) |
One screen per "source type." A connector groups its data into source types — the names the source system uses for its own things (for example Airthings exposes building, space, and thing). Each source type gets its own section in the connector's settings, and each section is its own Entities screen with its own Grid and Rules. So a customer might see a Buildings screen, a Spaces screen, and a Devices screen, and work through them one at a time.
During a migration you may see both. A connector that's being moved from the old model to Entities can temporarily show the old mapping table and a red "Entities Migration" section. Migration is a one-time rename that keeps all existing data and history intact. Documentation on migration to be released soon.
Two tabs at the top:
Two buttons at the top-right:
Above the Grid there's a smart search bar which can narrow what an Import pulls in. Depending on the connector, there may be filter fields (for example a Site picker or a sensor-type selector). Some connectors have no filters. Previous filter selections are saved between imports.
An empty Grid at the bottom reads "No entities found. Click Import to load entities from the connector."
Import fetches the current, live list of entities from the source system and lays them out as rows in the Grid. Each row is one entity — one building, one room, one sensor.
On import, for each row the connector provides:
Nothing is written to Mapped yet. Import only loads and shows — Save is what commits.
Import reflects the source system exactly. The list you get is the complete current state of that source type. If something exists in the source, it shows up; if it doesn't, it won't. That matters a lot for re-imports — see the next section.
Import isn't a one-time action. A customer can click Import again whenever the source system changes (new rooms added, a sensor decommissioned, a name corrected). When they do, the screen compares the fresh import against what's currently saved in Mapped and highlights the differences.
You'll see a change banner / count summarizing the delta, using three categories:
An entity that the source system no longer returns is shown struck through (a line through the whole row) in red, its checkbox unticked and disabled, and its cells read-only. The next Save will remove it from Mapped.
These "gone" rows can't be kept. If the source system dropped an entity, nobody — not the customer, not a merge rule — can re-select it to keep it. The only way to keep it is for it to reappear in the source system and be imported again. This is deliberate: Mapped mirrors the source.
Good news for customers: manual edits they made to a cell are remembered across re-imports. If a customer renamed a room and saved it, a later Import won't silently overwrite that name back to the source's version. (Two per-field flags — whether a cell is required and whether it must be unique — are refreshed on every import, because those are controlled by the connector, not the customer.)
Counts of any changes made sit at the bottom-left of the Grid:
| Count indicator | Color |
|---|---|
| 12 SELECTED | blue |
| 3 ADDED | green |
| 1 UPDATED | amber |
| 2 DELETED | red |
Only counts above zero appear. The bottom-right shows how much is loaded, e.g. 200 of 1450. Click any of these counts to filter the Grid down to those rows. For example, clicking 2 Deleted filters the Grid down to exactly those two rows. It is the fastest way to answer "what is about to be deleted?" The same counts appear in the Review Changes dialog, and clicking one there closes the dialog and applies the filter.
The Grid is editable. A customer can double-click into most cells and change the value — rename a space, set a floor level, pick which floor a room belongs to, and so on. Each column uses an editor suited to its type (a text box for a name, a number for a floor level, a searchable picker for "which floor").
Some cells don't hold plain text — they hold a link to another entity. For example, a Space has a "Floor" cell that points at the Floor it sits on. Editing that cell opens a tree picker of eligible places, and the customer selects one.
This is the single most useful thing to teach a customer. Every cell is color-coded by its source:
| What you see | What it means |
|---|---|
| Amber border | Unsaved edit — save to apply. |
| Dark-blue border, read-only | Set by a rule. Locked to manual edits. |
| Greyed text | Default value (placeholder) |
| No special styling | Value from the Graph. |
| Red border | Duplicate value in a unique field — must be unique to save. |
There's a legend behind the little help icon on the checkbox column header that explains these colors. If two apply at once, red wins over blue, blue wins over amber, amber wins over grey.
The first column of every row is a checkbox:
Save is the commit. It takes everything the customer has selected and edited and writes it to Mapped in one action.
Clicking Save opens a Review Changes dialog before anything is written. It shows:
The customer clicks Submit to proceed (or Cancel to back out).
On Submit, Mapped processes the whole batch at once:
After a successful Save, the freshly edited cells become "settled" (no more amber borders), and those entities are now live in Mapped — visible to the rest of the platform.
If a Save fails, the Save button turns amber and permanently displays: "Latest merge failed. Click here to try again." Clicking retries straight away, and no changes are lost.
Save is per-screen. Each source type (Buildings, Spaces, Devices…) has its own Import and its own Save. A customer works through and saves each screen.
1 . Open the connector → open a source-type section (e.g. Spaces).
2 . (Optional) set any filters above the Grid.
3 . Click Import → rows load into the Grid.
4 . Review the change counts if this isn't the first import.
5 . Tick the rows to include; edit cells as needed (watch the colours).
6 . (Optional) use the Rules tab to auto-fill — see Merge Rules
7 . Click Save → Review Changes → Submit.
8 . Repeat for the next source-type section.
Merge rules are the advanced configuration options for importing data into the Mapped platform from a connector. They let you automate what would otherwise be manual row-by-row work in the Grid: instead of ticking and editing hundreds of entities by hand, you write a small piece of logic that runs once for every imported entity and fills in values and/or ticks checkboxes automatically.
This document teaches you to read an existing rule and write your own. It's written so both Customer Success and customers can use it — merge rules are a supported, customer-facing part of configuring a connector, not an internal-only tool.
Merge rules are written in code (TypeScript). That's what makes them "advanced." You don't need to be a developer, but you do need to be comfortable reading and editing a short function. The Rules editor helps you: it autocompletes, checks your syntax as you type, and shows you exactly which fields exist for the connector you're configuring.
A merge rule is a single function that the platform runs against one entity at a time, for every row in the Grid, every time you Import or preview. For each entity, the rule can:
Whatever the rule sets shows up in the Grid as rule-owned cells (dark-blue border, read-only) and, where the rule sets selection, a rule-locked checkbox (disabled). Everything the rule doesn't touch is left to the customer's manual edits and the source defaults.
Why use them? Common jobs:
A rule applies one decision across thousands of rows, consistently, and re-applies automatically on every future import. A rule can point one entity at another by giving one of three things: the target's mapping key, its Mapped id, or its identity. For example:
Copy1entity.setField('floor', { type: 'FLOOR', mappingKey: 'msrc://…' })
If the field only accepts certain kinds of target (a Floor cell only accepts floors), it rejects anything else as you type — the same restriction the Grid picker enforces.
Each Entities screen has a Rules tab next to the Grid tab. It's a code editor (Monaco — the same engine as VS Code) with:
The editor opens on this starter template, which does nothing until you fill it in:
Copy1 2 3 4 5 6 7// Merge rules — runs once per imported entity. export default function rules(entity: RulesEntity): RulesEntity { // Example: // entity.setField('name', 'Renamed') // entity.setSelected(true) return entity }
Always check the types.d.ts tab first. It's generated from the real entity for that source type, so it lists the actual field slugs (name, floorLevel, floor, …) and context keys the connector emits. Don't guess names — read them off that tab.
A rule is just a function with a body. Read it as a list of "for this entity, do X." The building blocks are a handful of entity.* calls, split into reads and writes.
| Call | What it gives you |
|---|---|
| entity.getRefId() | The entity's ID in the source system. |
| entity.getContext('key') | Extra data the connector attached (e.g. a wing, a floor number, a status flag). Keys are listed in types.d.ts. |
| entity.getField('name') | The entity's current value for a field (default, or a prior manual edit). |
| entity.getMappedId() | The entity's existing Mapped ID, if it's already saved. |
| entity.getOrgId() / entity.getConnectorId() / entity.getConnectorTypeId() | Fixed identifiers for the org and connector — occasionally useful for conditions. |
Review the types.d.ts tab for a full list of read calls.
| Call | What it does |
|---|---|
| entity.setField('slug', value) | Sets a typed field value. The cell becomes rule-owned (dark-blue, read-only). |
| entity.setSelected(true | false) | Ticks (include) or unticks (exclude) the checkbox. The checkbox becomes rule-locked. |
A rule always returns the entity at the end (return entity) so the platform can apply the changes.
Copy1 2 3 4 5 6 7 8 9 10 11 12 13export default function rules(entity: RulesEntity): RulesEntity { // 1. Only include "real" leasable rooms — skip service closets, risers, etc. const code = entity.getField('spaceCode') // read the room code, e.g. "L12-204" const isLeasable = typeof code === 'string' && code.startsWith('L') entity.setSelected(isLeasable) // tick it only if it's leasable // 2. If the source didn't give this room a name, fall back to its code. if (!entity.getField('name') && code) { entity.setField('name', code) // set the name to the code } return entity // hand the entity back }
Read as: "Tick every room whose code starts with L; for any of those missing a name, use its code as the name; leave everything else alone." That single rule then applies to every row on every import.
The workflow:
Include or exclude by a condition:
Copy1 2 3 4 5export default function rules(entity: RulesEntity): RulesEntity { // Include only occupancy sensors; leave everything else unticked. entity.setSelected(entity.getContext('deviceKind') === 'occupancy') return entity }
Fill a missing value:
Copy1 2 3 4 5 6 7export default function rules(entity: RulesEntity): RulesEntity { // Give unnamed devices a name based on their source ID. if (!entity.getField('name')) { entity.setField('name', **Device ${entity.getRefId()}**) } return entity }
Combine reads:
Copy1 2 3 4 5 6 7 8 9 10 11export default function rules(entity: RulesEntity): RulesEntity { const floor = entity.getContext('floorNumber') // Only bring in entities that report a floor, and stamp it into the name. if (floor != null) { entity.setField('name', **${entity.getField('name')} (Floor ${floor})**) entity.setSelected(true) } else { entity.setSelected(false) } return entity }
This is the question customers ask most, so it gets its own section.
Every cell has a winning value, chosen from up to four sources in this priority order (highest wins):
1 . A merge rule — wins outright, locks the cell.
2 . An edit made in this session — typed but not saved yet.
3 . An edit saved earlier — survives re-imports.
4 . The value currently live in Mapped ← missing from the list above
5 . The source system's default.
In plain language:
These protect data integrity and win over everything, including a rule:
Rule of thumb: rules are for decisions you want made the same way every time; manual edits are for one-off corrections. If a cell is locked dark-blue, a rule owns it — change the rule, not the cell.
So you can iterate: tweak the rule → Save → Import later → the rule re-runs automatically.
If you maintain a reference of building identifiers, building names, and postal addresses, you can use a Grid Rule to automatically populate those values during import.
The rule matches each building using its source-system Ref ID, then applies the correct name and postal address automatically.
Using the building Ref ID is recommended because it is generally stable even if the building name changes over time.
| Building Ref ID | Canonical Building Name | Postal Address |
|---|---|---|
| <building-id-1> | Example Campus Building A | 100 Main Street, Springfield, MA 01103 |
| <building-id-2> | Example Campus Building B | 200 Main Street, Springfield, MA 01103 |
| <building-id-3> | Example Campus Building C | 300 Main Street, Springfield, MA 01103 |
Create an object that maps each building Ref ID to its canonical name and postal address.
Copy1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22const BUILDINGS: Record< string, { name: string postalAddress: string } > = { '<building-id-1>': { name: 'Example Campus Building A', postalAddress: '100 Main Street, Springfield, MA 01103', }, '<building-id-2>': { name: 'Example Campus Building B', postalAddress: '200 Main Street, Springfield, MA 01103', }, '<building-id-3>': { name: 'Example Campus Building C', postalAddress: '300 Main Street, Springfield, MA 01103', }, }
The rule looks up the imported building by Ref ID. If a matching entry exists, it applies the canonical name and postal address and selects the building.
Copy1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16export default function rules(entity: RulesEntity): RulesEntity { const building = BUILDINGS[entity.getRefId()] // Unknown buildings are left untouched so they appear in the // Grid for review instead of being assigned incorrect values. if (!building) { return entity } entity.setField('name', building.name) entity.setField('postalAddress', building.postalAddress) entity.setSelected(true) return entity }
Replace the example Ref IDs, building names, and addresses with values for your buildings.
Copy1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33const BUILDINGS = { 'building-ref-id-1': { name: 'Example Campus Building A', postalAddress: '100 Main Street, Springfield, MA 01103', }, 'building-ref-id-2': { name: 'Example Campus Building B', postalAddress: '200 Main Street, Springfield, MA 01103', }, 'building-ref-id-3': { name: 'Example Campus Building C', postalAddress: '300 Main Street, Springfield, MA 01103', }, } export default function rules(entity: RulesEntity): RulesEntity { const building = BUILDINGS[entity.getRefId()] // If this building is not in the lookup table, // leave it unchanged so it can be reviewed. if (!building) { return entity } entity.setField('name', building.name) entity.setField('postalAddress', building.postalAddress) entity.setSelected(true) return entity }
Do I have to use rules? No. They're optional advanced configuration. Connectors can be set up entirely by ticking and editing in the Grid.
Can a rule delete entities? A rule can untick a row (setSelected(false)), which un-selects an already-saved entity on the next Save. Items that are un-selected and then saved are intended to be removed from the Mapped Graph, but there is currently no capability to delete entities from the connector runtime. There's work for that to be a possibility in the future. In the meantime, the entity may become an orphan in the Graph.
What if the rule has an error? The editor shows it in the status bar and underlines it in the code. It can be saved so that you save your work.
What kind of errors can happen with rules? There are two different kinds of "error", and only one shows up in the Rules editor. First, the editor may complain (red underline, orange status bar), which indicates the rule doesn't quite typecheck. It still saves and still runs.
Second, the rule may crash while running, and this is indicated in the Grid, not the editor. In this case the rule does not apply. On every affected row, a red icon replaces the checkbox, and the row can't be selected or deselected until the rule is fixed. Hovering it says whether the rule failed on that one row or on every row.
Where do I see what fields I can use? The read-only types.d.ts tab in the Rules editor — it's generated per connector and always current.