Entities by API
Introduction
You can use the same GraphQL operations behind the Connector Entities to import, preview, apply Merge Rules, and save connector entities programmatically.
| Endpoint | https://api.mapped.com/graphql | Authorization | token <PAT> | Schema | api.mapped.com/docs | | --- | --- | --- |
This guide covers the API workflow behind Connector Entities. It is written for the same audience as the existing Entities documentation: people who understand the connector workflow and want to automate repetitive work without changing the product model.
The examples use nuvolo-source because it provides a concrete building / floor / space hierarchy. The API flow itself is connector-agnostic. For another connector, the connector ID, source type names, field slugs, context keys, and allowed reference types will be different.
IMPORTANT: The UI remains the easiest way to understand the workflow. The API does not create a separate model. It exposes the same source types, staged entities, preview behavior, rules, and Save operation that the Entities screens use.
What does the Entities API let you do?
Everything in the normal Entities flow can be driven through GraphQL: discover the connector's source types, Import a fresh snapshot, inspect the current staged rows, Preview edits and Merge Rules, and Save the resulting changeset.
| In the Entities screen | Over the API |
|---|---|
| Source-type sections | listConnectorEntitySourceTypes |
| Grid at rest | listConnectorEntities |
| Import | importConnectorEntities |
| Grid preview, counts, validation | previewConnectorEntities |
| Rules editor type reference | getConnectorEntityRulesDeclarationTypes |
| Save | mergeConnectorEntities |
| Job progress / recent jobs | jobs |
A useful way to think about the flow is: source system → Import into staging → Preview → Save → Mapped. Import and Preview do not write entity changes to the Graph. Save is the commit step.
Quick reference - the whole API flow
- Identify the connector and list its source types.
- Read the latest Import and Save jobs, row counts, generated rule types, and any rule currently in effect.
- Import when you actually need a fresh snapshot from the source system.
- Prepare explicit row edits, a Merge Rule, or both.
- Preview the exact payload you intend to Save.
- Review counts, blocked rows, duplicate values, and runtime rule errors.
- Save only after the preview matches the intended change.
- Poll the Save job to completion, then refresh state.
What should I know before I start?
Every operation is scoped to a namespace. A namespace is the combination of a connector instance and one source type.
| Term | Meaning |
|---|---|
| connectorId | The connector instance. Every Entities API call is scoped to one connector. |
| sourceType | The source system's name for an imported entity type, such as building, floor, space, or thing. |
| namespace | connectorId, sourceType. Import, Preview, Save, and rules act on one namespace at a time. |
| entity | One imported row, including its refId, mappingKey, exact type, source context, and editable fields. |
| field | A typed editable value such as NAME, FLOOR_LEVEL, SPACE_CODE, POSTAL_ADDRESS, or ENTITY_REF. |
| mergingState | The staged / in-progress / merged state of the row. |
WHY PREVIEW IS SAFE: Import writes a source snapshot into staging. Preview evaluates a proposed changeset without committing it. Save is the step that writes selected entity changes into Mapped.
How do I list the connector's source types?
Do not guess source type names. Read them from the connector manifest with listConnectorEntitySourceTypes. These are the same sections a customer sees in the connector settings UI.
GraphQL - listConnectorEntitySourceTypes
query sourceTypes($input: ConnectorEntityListSourceTypesRequestInput) {
listConnectorEntitySourceTypes(input: $input) {
types { sourceType baseTypes }
}
}
# variables
{ "input": { "connectorId": "CON…" } }
Response — nuvolo-source declares three:
{ "types": [
{ "sourceType": "building", "baseTypes": ["BUILDING"] },
{ "sourceType": "floor", "baseTypes": ["FLOOR"] },
{ "sourceType": "space", "baseTypes": ["SPACE"] } ] }
If you need the connector ID first, query the connector list and identify the row for the connector instance you are working with. The examples below use nuvolo-source.
What fields and context does a namespace expose?
Fields are editable values that can be sent in a Save. Context is read-only source data attached by the connector and is often the best input for a Merge Rule. Required fields determine whether a row is selectable.
| Namespace | Editable fields in the example | Example context keys |
|---|---|---|
| building | name; postalAddress; site (required ENTITY_REF to SITE) | realEstateId; number; spaceValue |
| floor | name; floorLevel; building (required ENTITY_REF to BUILDING or SUB_BUILDING) | buildingId; number; spaceValue |
| space | name; spaceCode; floor (required ENTITY_REF to FLOOR) | floorId; typeHint; number; area; areaUnit; elocationId; elocationNumber |
ENTITY REFERENCES: An ENTITY_REF points to a place, not necessarily to a row imported by the same connector. A target can be identified by mappedId, mappingKey, or identity, subject to the field's allowed target types.
How do I read the current state before making changes?
Start from what is actually in effect. The UI does this implicitly when the Entities screen opens. In a script, collect the same information before editing anything: source types, the latest Import job, the latest Save job, staged and merged counts, the generated rule declarations, and the rule stored on the most recent Save job.
RULE STORAGE: A Merge Rule is stored with the merge job that saved it. To recover the rule currently in effect for a namespace, read the latest mergeEntities job params and decode its stored TypeScript.
GraphQL - latest jobs
query latestJob($filter: JobListFilterInput, $latest: Boolean) {
jobs(filter: $filter, latest: $latest) {
id name state percentageComplete params createdAt createdBy completedAt
}
}
# variables — job names are `{operation}/{connectorId}/{sourceType}`
{ "latest": true,
"filter": {
"parentResourceId": { "eq": "CON…" },
"name": { "in": ["mergeEntities/CON…/space"] }
} }
# params of a mergeEntities job
{ "commitMessage": "REQ-4821 — space names for building 5138",
"rules": { "typescript": "<base64>", "javascript": "<base64>" } }
# params of an importEntities job
{ "params": { /* whatever was passed to the import */ } }
| Source | Suggested local file | Why keep it |
|---|---|---|
| listConnectorEntitySourceTypes | .mapped/state.json | The actual source type names. |
| latest importEntities job | .mapped/state.json | When the source was last refreshed and which Import params were used. |
| latest mergeEntities job | rules/<sourceType>.ts | The saved Merge Rule currently in effect. |
| getConnectorEntityRulesDeclarationTypes | .mapped/types/<sourceType>.d.ts | The generated fields, context keys, and allowed reference types. |
| listConnectorEntities | .mapped/state.json | Staged and merged row counts. |
Example - .mapped/state.json
{
"connectorId": "CON…",
"fetchedAt": "2026-09-11T15:42:07.418Z",
"namespaces": [
{
"sourceType": "space",
"baseTypes": ["SPACE"],
"staged": 4820,
"merged": 4611,
"lastImport": {
"id": "JOB…", "state": "SUCCEEDED",
"completedAt": "2026-09-10T23:04:11Z",
"params": {}
},
"lastMerge": {
"id": "JOB…", "state": "SUCCEEDED",
"completedAt": "2026-09-10T23:22:57Z",
"commitMessage": "REQ-4821 — space names for building 5138",
"rulesFile": "rules/space.ts", "restored": true
},
"typesFile": ".mapped/types/space.d.ts"
}
]
}
A state command should be read-only. Run it at the start of a working session and again after any Import or Save job finishes. If you restore a stored rule to disk, do not overwrite a local rule file that already contains work in progress.
What happens when I Import over the API?
Import fetches the current list for one source type and replaces that namespace's staged source snapshot. The API returns a job immediately; poll that job until it succeeds or fails.
GraphQL - importConnectorEntities
mutation importEntities($input: ConnectorEntityImportRequestInput) {
importConnectorEntities(input: $input) {
job { id name state percentageComplete }
}
}
# variables — `params` is free-form and reaches the connector's import function
{ "input": {
"namespace": { "connectorId": "CON…", "sourceType": "space" },
"params": { }
} }
Poll (jobs is the generic job query)
query job($filter: JobListFilterInput) {
jobs(filter: $filter) { id state percentageComplete }
}
{ "filter": { "id": { "eq": "JOB…" } } } # → PENDING | RUNNING | SUCCEEDED | FAILED
IMPORT IS A SNAPSHOT: Use Import parameters carefully. If an Import is scoped so narrowly that previously reported rows are no longer returned, those rows are treated as missing from the source snapshot. Re-import the correct complete scope to recover them.
Only one Import or Save job can run for a namespace at a time. If another job is active, a second write operation returns FAILED_PRECONDITION. Reuse the previous Import params when you intend to refresh the same scope rather than inventing a new scope accidentally.
Are namespaces dependent on each other?
At the platform level, namespaces are independent. A Space can reference a Floor that was merged by another connector or created manually. An ENTITY_REF identifies a place in the Graph; it is not restricted to the same connector namespace.
A particular connector may still need a parent entity before its own Import can query the source system. That is a connector implementation detail, not a rule of the Entities API. nuvolo-source, for example, queries floors per merged building and spaces per merged floor.
Example - connector-specific sequencing errors
No merged building to scope floors by. Merge at least one building in the
Buildings section, then import floors.
No merged floor to scope spaces by. Merge at least one floor in the Floors
section, then import spaces.
What happens when I Preview changes?
Preview is the API equivalent of seeing the Grid after edits and rules have been applied. It returns the change counts, the rows that would be affected, validation results, field value sources, and any runtime rule errors. It does not commit the changes.
Use the exact same items and rules payload you plan to Save. The safest workflow is to keep previewing until the counts and examples match the intended change, then send that same changeset to Save.
GraphQL - previewConnectorEntities
mutation preview($input: ConnectorEntityPreviewRequestInput) {
previewConnectorEntities(input: $input) {
counts { added updated deleted selected }
list {
entities {
id refId mappingKey mappedId mergingState context
fields {
name label required type
value { name floorLevel spaceCode subBuildingCode postalAddress
entityRef { type mappedId mappingKey
identity { entityType scope scopeId value } } }
}
}
pagination { total nextToken noMoreData }
}
}
}
# variables
{ "input": {
"namespace": { "connectorId": "CON…", "sourceType": "space" },
"merge": {
"items": [ { "entityId": "NTTY…", "action": "MERGE",
"fields": [ { "name": "name", "value": { "name": "Conference 12-04" } },
{ "name": "spaceCode", "value": { "spaceCode": "12-04" } } ] } ],
"rules": { "typescript": "<base64 of rules/space.ts>",
"javascript": "<base64 of its transpiled JS>" }
},
"list": {
"filter": { "searchQuery": "#added conference" },
"pagination": { "limit": 200 }
}
} }
How do I read a Preview response?
The top-level counts describe the entire namespace, not only the filtered page returned in list. Each returned entity also carries preview metadata in context.__preview.
| Preview value | What it tells you |
|---|---|
| tags[] | Whether the row is added, updated, deleted/removed, selected, or missing. Tags can be additive. |
| fields[slug] | Which source won the displayed value: RULE, MANUAL, GRAPH, or DEFAULT. |
| selectable | false when a required field is empty or a unique value conflicts. The row cannot be included until the issue is fixed. |
| missingRequired[] | Required fields that are still empty. |
| duplicateUnique[] | Unique fields that collide with another row. |
| rulesError | A runtime Merge Rule error. entity scope affects one row; rule scope indicates the rule itself failed broadly. |
DELETED / REMOVED ROWS: Use the current product meaning when explaining these rows: a source-missing or deselected row is removed from connector Entities tracking on Save. It should not be described as a hard delete of the underlying Graph entity; current Entities behavior does not delete that Graph entity.
Preview filtering supports standard identifiers and change metadata, plus free-text searchQuery and tokens such as #selected, #added, #updated, and #deleted. For a plain read of staged data without a proposed changeset, use listConnectorEntities instead.
How do explicit edits and Merge Rules work together?
There are two ways to express changes, and they can be used together.
| Method | Best for | How it behaves |
|---|---|---|
| items[] | Specific row-by-row decisions that your script has already calculated. | Equivalent to explicit checkbox choices and cell edits. |
| rules | Repeatable logic that should run across every imported row. | Equivalent to the Rules tab. A rule can set fields and selection. |
Rule-owned values take priority for the paths the rule actually sets. Fields and selection that the rule does not touch remain under the normal manual / saved / Graph / source-default precedence used by the Grid.
This matches the UI behavior: a field controlled by a Merge Rule is read-only, while unrelated fields remain editable. A rule that only calls setSelected does not take ownership of the entity name or other fields.
How do I write a Merge Rule safely?
Read the generated declarations for the namespace before writing code. The API generates the same type surface used by the Rules editor. It tells you the actual field slugs, context keys, literal source type, and allowed entity-reference types for that connector.
GraphQL - getConnectorEntityRulesDeclarationTypes
query dts($input: ConnectorEntityGetRulesDeclarationTypesRequestInput) {
getConnectorEntityRulesDeclarationTypes(input: $input) { declarationTypes }
}
{ "input": { "namespace": { "connectorId": "CON…", "sourceType": "space" } } }
# declarationTypes is base64 — decode it to a .d.ts file
Generated TypeScript - example excerpt
export declare class RulesEntity {
getOrgId(): "ORGXSW52pCZwvZWumDzBgd2Xh"
getConnectorId(): "CON…"
getConnectorTypeId(): "nuvolo-source"
getId(): string
getRefId(): string
getSourceType(): "space"
getBaseType(): ConnectorEntityBaseType
getMappingKey(): string
getExactType(): EntityTypeKey
getMappedId(): string | undefined
getContext(key: string): any
setField(slug: string, value: unknown): this
setSelected(selected: boolean): this
}
/** A reference to set on a relationship field — provide exactly one identifier. */
export type EntityRefInput =
| { type: EntityTypeKey; mappingKey: string }
| { type: EntityTypeKey; mappedId: string }
| { type: EntityTypeKey; identity: EntityRefIdentity }
Do not assume a method exists because another connector has it. For example, getField may be absent from a generated declaration in some environments. If it is not present in the generated type, derive the value from context instead.
TypeScript - example Merge Rule
import type { RulesEntity } from '../.mapped/types/space'
// Field slugs (`name`, `spaceCode`, `floor`) and context keys (`typeHint`,
// `number`, …) are this namespace's own — they come from the generated .d.ts,
// not from convention. Here: the Nuvolo space types worth merging, `typeHint`
// being the display value of x_nuvo_eam_space.type. Read the real values off a
// preview before trusting a list like this.
const TRACKED = ['Conference Room', 'Office', 'Lab']
// Runs once per row, inside a sandbox. Type-only imports are erased on
// transpile — a rule must be one self-contained module.
export default function rules(entity: RulesEntity): RulesEntity {
const number = entity.getContext('number') as string | undefined
const typeHint = entity.getContext('typeHint') as string | undefined
if (!number) {
return entity // no opinion on this row
}
// Nuvolo's display_name is often the bare room number. Give the grid
// something a person can read.
entity.setField('name', typeHint ? `${typeHint} ${number}` : number)
if (TRACKED.includes(typeHint ?? '')) {
entity.setSelected(true) // → MERGE
}
return entity
}
| Call | Purpose |
|---|---|
| getRefId(), getMappingKey(), getSourceType(), getExactType(), getBaseType(), getId() | Read stable identifiers for the imported row. |
| getContext(key) | Read source context attached by the connector. |
| getMappedId() | Read the Graph entity ID if this row has already been saved. |
| getField(slug) | Read a current field value when the generated type exposes this method. |
| getOrgId(), getConnectorId(), getConnectorTypeId() | Read fixed execution scope. |
| setField(slug, value) | Set a typed field value. |
| setSelected(true | false) | Control whether the row is included. For a previously saved row, false removes it from connector tracking on Save; it does not currently delete the Graph entity. |
RULE SANDBOX: Merge Rules run synchronously in an isolated interpreter. Do not use await, fetch, file or network access, or runtime imports. External lookup data must be prepared by your script and embedded into the rule source. Runtime failures are reported in Preview so they can be fixed before Save.
What happens when I Save over the API?
Save is the commit step. mergeConnectorEntities takes the namespace, the exact row items you previewed, the Merge Rule payload if present, and a commit message. It returns a job; poll it the same way as Import.
GraphQL - mergeConnectorEntities
mutation save($input: ConnectorEntityMergeRequestInput) {
mergeConnectorEntities(input: $input) { job { id name state } }
}
{ "input": {
"namespace": { "connectorId": "CON…", "sourceType": "space" },
"commitMessage": "REQ-4821 — space names for building 5138, level 12",
"items": [ /* identical to the previewed items */ ],
"rules": {
"typescript": "<base64 of rules/space.ts — your source, kept for round-trip>",
"javascript": "<base64 of that same file transpiled to JavaScript — this is what runs>"
}
} }
SEND BOTH RULE REPRESENTATIONS: The rules payload contains two base64 values. javascript is the transpiled code the sandbox executes. typescript is the source retained for the Rules editor and later recovery. Transpile the TypeScript once and send both values so execution and audit history stay aligned.
After a successful Save, selected rows are created or updated with the previewed values. Rows removed by selection or because the source no longer reports them are removed from connector tracking. Current Entities behavior does not delete the underlying Graph entity.
Run your state step again after Save. The new merge job is now the source of truth for the stored rule and commit message.
FAILED_PRECONDITION: For Save, this usually means another job is already running for the namespace or the proposed changeset is empty. Treat it as a state condition to inspect, not as a reason to force another write.
How do I filter by a parent or grandparent that is not on the row?
A row normally carries only the relationships its source type declares. For example, a Space may reference a Floor, while the Floor references a Building. Asking for "every space in Building 5138" therefore requires a client-side join across those namespaces.
- Read the parent namespace and identify the parent entity you want.
- Read the intermediate namespace and keep the rows whose entity reference points at that parent.
- Build a set of identifiers for those intermediate rows.
- Read the child namespace and keep the rows whose immediate-parent reference matches that set.
- Turn the result into explicit items[] or embed the resolved set into a Merge Rule, then Preview before Save.
Match entity references using the identifier forms the platform may store, especially mappedId and mappingKey. This also lets the same pattern work when the parent came from another connector.
TypeScript - client-side hierarchy join
import { list, preview, type Entity } from './mapped.js'
// Pick the parent. Here: the Nuvolo building number, which nuvolo-source puts
// in `context.number`. On another connector this is whatever identifies the
// parent row — read the context keys off `npm run preview <parent>`.
const BUILDING_NUMBER = '5138'
// Generated types: every field is nullable, because the schema says so.
const refOf = (e: Entity, slug: string) =>
e.fields?.find(f => f?.name === slug)?.value?.entityRef
// A reference names a PLACE, in whichever form was saved: a graph id, a
// mappingKey, or an identity. Two rows point at the same place when any of
// those forms match, so index a row under every key it can be referenced by —
// its graph id and the `?refId=` tail of its own mappingKey.
const keysOf = (e: Entity) =>
[e.mappedId, e.mappingKey?.split('refId=')[1]].filter(Boolean) as string[]
const refKeys = (e: Entity, slug: string) => {
const ref = refOf(e, slug)
return [ref?.mappedId, ref?.mappingKey?.split('refId=')[1]].filter(Boolean) as string[]
}
const hits = (set: Set<string>, keys: string[]) => keys.some(k => set.has(k))
// 1 — the building, by its Nuvolo number
const building = (await list('building')).find(
b => String(b.context?.number ?? '') === BUILDING_NUMBER,
)
if (!building) {
throw new Error(`no building with number ${BUILDING_NUMBER}`)
}
// 2 — the floors pointing at it. A floor merged by ANOTHER connector would be
// found the same way, by listing that connector's namespace instead.
const buildingKeys = new Set(keysOf(building))
const floorKeys = new Set(
(await list('floor'))
.filter(f => hits(buildingKeys, refKeys(f, 'building')))
.flatMap(keysOf),
)
// 3 — the spaces sitting on those floors
const spaces = (await list('space')).filter(s =>
hits(floorKeys, refKeys(s, 'floor')),
)
// 4 — one explicit intent per row, previewed before anything is written
const items = spaces.map(s => ({ entityId: s.id, action: 'MERGE' as const }))
const { counts } = await preview('space', { items })
console.log(`${spaces.length} spaces under building ${BUILDING_NUMBER} →`, counts)
How do I set up a reusable TypeScript harness?
For repeated work, a small local harness is easier to review and safer than assembling one-off GraphQL requests. The pattern below uses tsx to run TypeScript directly, esbuild to transpile Merge Rules, TypeScript to typecheck them, and GraphQL Code Generator to generate request and response types from the platform schema.
The harness is connector-agnostic. Put one connector ID in .env, discover its source types at runtime, and keep that connector's rules, generated declarations, state snapshots, and Preview history in the same working folder.
1. Create the project
Shell - setup
mkdir mapped-entities && cd mapped-entities
npm init -y
npm i -D tsx esbuild typescript @types/node \
graphql @graphql-codegen/cli @graphql-codegen/typescript-operations
# drop in the files below, then bootstrap once:
npm run schema # → .mapped/schema.json + .mapped/schema.graphql
npm run codegen # → .mapped/graphql.ts
.env
GRAPHQL_API_URI=https://api.mapped.com/graphql
MAPPED_PAT=PAT…
CONNECTOR_ID=CON…
SOURCE_TYPE=space
package.json
{
"name": "mapped-entities",
"private": true,
"type": "module",
"scripts": {
"schema": "tsx mapped.ts schema",
"codegen": "graphql-codegen --config codegen.ts",
"state": "tsx mapped.ts state",
"import": "tsx mapped.ts import",
"preview": "tsx mapped.ts preview",
"check": "tsc --noEmit",
"save": "tsx mapped.ts save"
},
"devDependencies": {
"@graphql-codegen/cli": "^5.0.0",
"@graphql-codegen/typescript-operations": "^4.4.0",
"@types/node": "^22.0.0",
"esbuild": "^0.25.0",
"graphql": "^16.9.0",
"tsx": "^4.19.0",
"typescript": "^5.6.0"
}
}
tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["*.ts", "rules/*.ts", ".mapped/types/*.d.ts"]
}
2. Keep GraphQL operations in one file
Store the requests in operations.graphql and generate TypeScript types from the actual platform schema. This prevents the request text and the code's assumed response shape from drifting apart.
operations.graphql
# Every request this harness makes, in one file. It is the single source of
# truth: `npm run codegen` types it against the real schema, and mapped.ts
# sends it verbatim with an `operationName`.
query sourceTypes($input: ConnectorEntityListSourceTypesRequestInput) {
listConnectorEntitySourceTypes(input: $input) {
types { sourceType baseTypes }
}
}
query declarationTypes($input: ConnectorEntityGetRulesDeclarationTypesRequestInput) {
getConnectorEntityRulesDeclarationTypes(input: $input) { declarationTypes }
}
query jobs($filter: JobListFilterInput, $latest: Boolean) {
jobs(filter: $filter, latest: $latest) {
id name state percentageComplete params completedAt
}
}
fragment EntityFields on ConnectorEntity {
id
refId
mappingKey
mappedId
mergingState
context
fields {
name
label
required
type
value {
name
floorLevel
spaceCode
subBuildingCode
postalAddress
entityRef {
type
mappedId
mappingKey
identity { entityType scope scopeId value }
}
}
}
}
query listEntities($input: ConnectorEntityListRequestInput) {
listConnectorEntities(input: $input) {
entities { ...EntityFields }
pagination { total nextToken noMoreData }
}
}
query countEntities($input: ConnectorEntityListRequestInput) {
listConnectorEntities(input: $input) { pagination { total } }
}
mutation importEntities($input: ConnectorEntityImportRequestInput) {
importConnectorEntities(input: $input) { job { id state } }
}
mutation previewEntities($input: ConnectorEntityPreviewRequestInput) {
previewConnectorEntities(input: $input) {
counts { added updated deleted selected }
list {
entities { ...EntityFields }
pagination { total }
}
}
}
mutation mergeEntities($input: ConnectorEntityMergeRequestInput) {
mergeConnectorEntities(input: $input) { job { id state } }
}
3. Generate operation types from the platform schema
codegen.ts
import type { CodegenConfig } from '@graphql-codegen/cli'
// Reads the schema dump `npm run schema` wrote, so codegen itself needs no
// credentials and no network.
//
// One plugin is enough: `typescript-operations` emits the input types, the
// enums and the per-operation result types the documents actually touch.
// Adding `typescript` on top emits every input a SECOND time and tsc refuses
// the file with "Duplicate identifier".
const config: CodegenConfig = {
schema: '.mapped/schema.json',
documents: 'operations.graphql',
generates: {
'.mapped/graphql.ts': {
plugins: ['typescript-operations'],
config: {
skipTypename: true,
scalars: { GraphQLJSON: 'Record<string, any>', Long: 'number' },
},
},
},
}
export default config
The original harness uses the typescript-operations plugin by itself because it emits the operation variables, results, inputs, and enums needed by these documents. Re-run schema, codegen, and the TypeScript check after a platform change so the compiler identifies any shape changes before a Save.
4. Use one typed command-line harness
The following mapped.ts implementation covers schema download, state recovery, listing, Import, Preview, Save, job polling, rule transpilation, and a small CLI.
mapped.ts
// Mapped Entities harness. Run with tsx: `npx tsx mapped.ts state`
//
// Bootstrap once, in this order:
// npm run schema → .mapped/schema.json + .mapped/schema.graphql
// npm run codegen → .mapped/graphql.ts (types for every operation below)
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { transform } from 'esbuild'
import { buildClientSchema, getIntrospectionQuery, printSchema } from 'graphql'
import type {
ConnectorEntityListFilterInput,
ConnectorEntityMergeRequestItemInput,
CountEntitiesQuery,
CountEntitiesQueryVariables,
DeclarationTypesQuery,
DeclarationTypesQueryVariables,
EntityFieldsFragment,
ImportEntitiesMutation,
ImportEntitiesMutationVariables,
JobsQuery,
JobsQueryVariables,
ListEntitiesQuery,
ListEntitiesQueryVariables,
MergeEntitiesMutation,
MergeEntitiesMutationVariables,
PreviewEntitiesMutation,
PreviewEntitiesMutationVariables,
SourceTypesQuery,
SourceTypesQueryVariables,
} from './.mapped/graphql'
// --- config -----------------------------------------------------------------
const env: Record<string, string> = Object.fromEntries(
readFileSync(new URL('.env', import.meta.url), 'utf8')
.split('\n')
.filter(l => l.trim() && !l.startsWith('#'))
.map(l => [l.slice(0, l.indexOf('=')).trim(), l.slice(l.indexOf('=') + 1).trim()]),
)
const CONNECTOR_ID = env.CONNECTOR_ID
const ns = (sourceType?: string) => ({
connectorId: CONNECTOR_ID,
sourceType: sourceType ?? env.SOURCE_TYPE,
})
const b64 = (s: string) => Buffer.from(s, 'utf8').toString('base64')
const unb64 = (s: string) => Buffer.from(s, 'base64').toString('utf8')
const local = (p: string) => new URL(p, import.meta.url)
const present = <T>(v: T | null | undefined): v is T => v != null
// Everything the grid shows for one row. The generated fragment type — so a
// field you did not select is a compile error, not a runtime undefined.
export type Entity = EntityFieldsFragment
export type Job = NonNullable<NonNullable<JobsQuery['jobs']>[number]>
export type Counts = NonNullable<
NonNullable<PreviewEntitiesMutation['previewConnectorEntities']>['counts']
>
export type MergeItem = ConnectorEntityMergeRequestItemInput
// --- transport --------------------------------------------------------------
// operations.graphql is sent whole; `operationName` picks the one to run. One
// file is both what codegen types and what the server executes, so the two can
// never drift.
const OPERATIONS = readFileSync(local('operations.graphql'), 'utf8')
// One POST. Everything else goes through `request` below.
export async function post<TData>(body: Record<string, unknown>, label: string): Promise<TData> {
const res = await fetch(env.GRAPHQL_API_URI, {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `token ${env.MAPPED_PAT}`,
},
body: JSON.stringify(body),
})
const json = (await res.json()) as { data?: TData; errors?: unknown[] }
if (json.errors?.length) {
throw new Error(`${label}: ${JSON.stringify(json.errors, null, 2)}`)
}
return json.data as TData
}
// Runs one operation out of operations.graphql, typed by codegen.
export const request = <TData, TVariables>(operationName: string, variables: TVariables) =>
post<TData>({ query: OPERATIONS, operationName, variables }, operationName)
// --- schema: pull it down and keep it on disk -------------------------------
// Writes the introspection result (what codegen reads) and the SDL (what you
// and your agent grep). Re-run after any platform release.
export async function schema() {
// Introspection is not one of our operations — it is the server describing
// itself, so it goes straight to `post`.
const data = await post<Record<string, unknown>>(
{ query: getIntrospectionQuery({ descriptions: true }) },
'introspection',
)
mkdirSync(local('.mapped'), { recursive: true })
writeFileSync(local('.mapped/schema.json'), JSON.stringify(data, null, 2))
writeFileSync(
local('.mapped/schema.graphql'),
printSchema(buildClientSchema(data as never)),
)
return { json: '.mapped/schema.json', sdl: '.mapped/schema.graphql' }
}
// --- rules: TypeScript on disk, {typescript, javascript} on the wire ---------
export type RulesPayload = { typescript: string; javascript: string }
// Reads rules/<sourceType>.ts and returns BOTH halves the API expects: the
// source (stored, round-tripped by the console) and its transpiled JavaScript
// (the only half that is executed).
export async function loadRules(sourceType?: string): Promise<RulesPayload | undefined> {
const file = local(`rules/${sourceType ?? env.SOURCE_TYPE}.ts`)
if (!existsSync(file)) {
return undefined
}
const typescript = readFileSync(file, 'utf8')
const { code } = await transform(typescript, {
loader: 'ts',
format: 'esm',
target: 'es2020',
})
// The sandbox has no module loader: a surviving import means a broken rule.
if (/(^|\n)\s*import\s/.test(code) || /\brequire\s*\(/.test(code)) {
throw new Error('rule must be self-contained — use `import type` only')
}
return { typescript: b64(typescript), javascript: b64(code) }
}
// --- operations -------------------------------------------------------------
export const sourceTypes = () =>
request<SourceTypesQuery, SourceTypesQueryVariables>('sourceTypes', {
input: { connectorId: CONNECTOR_ID },
}).then(d => (d.listConnectorEntitySourceTypes?.types ?? []).filter(present))
export const declarationTypes = (sourceType?: string) =>
request<DeclarationTypesQuery, DeclarationTypesQueryVariables>('declarationTypes', {
input: { namespace: ns(sourceType) },
}).then(d =>
unb64(d.getConnectorEntityRulesDeclarationTypes?.declarationTypes ?? ''),
)
export const latestJob = (op: 'importEntities' | 'mergeEntities', sourceType?: string) =>
request<JobsQuery, JobsQueryVariables>('jobs', {
filter: {
parentResourceId: { eq: CONNECTOR_ID },
name: { in: [`${op}/${CONNECTOR_ID}/${sourceType ?? env.SOURCE_TYPE}`] },
},
latest: true,
}).then(d => (d.jobs ?? []).filter(present)[0])
// Every page of a namespace, as plain rows.
export async function list(
sourceType?: string,
filter?: ConnectorEntityListFilterInput,
): Promise<Entity[]> {
const out: Entity[] = []
let nextToken: string | null | undefined
do {
const d = await request<ListEntitiesQuery, ListEntitiesQueryVariables>('listEntities', {
input: { namespace: ns(sourceType), filter, pagination: { limit: 500, nextToken } },
})
// An empty namespace answers `entities: null`, not `[]` — the generated
// types say so, which is why this is a filter and not a spread.
out.push(...(d.listConnectorEntities?.entities ?? []).filter(present))
nextToken = d.listConnectorEntities?.pagination?.nextToken
} while (nextToken)
return out
}
// Row count only — one row fetched, the total read off the pagination block.
export const count = (sourceType?: string, filter?: ConnectorEntityListFilterInput) =>
request<CountEntitiesQuery, CountEntitiesQueryVariables>('countEntities', {
input: { namespace: ns(sourceType), filter, pagination: { limit: 1 } },
}).then(d => d.listConnectorEntities?.pagination?.total ?? 0)
export const importEntities = (sourceType?: string, params: Record<string, unknown> = {}) =>
request<ImportEntitiesMutation, ImportEntitiesMutationVariables>('importEntities', {
input: { namespace: ns(sourceType), params },
}).then(d => waitForJob(d.importConnectorEntities?.job?.id ?? ''))
export async function preview(
sourceType?: string,
opts: { items?: MergeItem[]; filter?: ConnectorEntityListFilterInput; limit?: number } = {},
) {
const d = await request<PreviewEntitiesMutation, PreviewEntitiesMutationVariables>(
'previewEntities',
{
input: {
namespace: ns(sourceType),
merge: { items: opts.items ?? [], rules: await loadRules(sourceType) },
list: { filter: opts.filter, pagination: { limit: opts.limit ?? 100 } },
},
},
)
const result = {
counts: d.previewConnectorEntities?.counts ?? ({} as Counts),
entities: (d.previewConnectorEntities?.list?.entities ?? []).filter(present),
}
mkdirSync(local('runs'), { recursive: true })
writeFileSync(
local(`runs/${new Date().toISOString().replace(/[:.]/g, '-')}.json`),
JSON.stringify(result, null, 2),
)
return result
}
export async function save(
sourceType: string | undefined,
commitMessage: string,
items: MergeItem[] = [],
) {
if (!commitMessage?.trim()) {
throw new Error('a commit message is required — name the request it serves')
}
const d = await request<MergeEntitiesMutation, MergeEntitiesMutationVariables>(
'mergeEntities',
{
input: {
namespace: ns(sourceType),
commitMessage,
items,
rules: await loadRules(sourceType),
},
},
)
return waitForJob(d.mergeConnectorEntities?.job?.id ?? '')
}
export async function waitForJob(id: string, timeoutMs = 900_000): Promise<Job> {
const deadline = Date.now() + timeoutMs
for (;;) {
const d = await request<JobsQuery, JobsQueryVariables>('jobs', { filter: { id: { eq: id } } })
const job = (d.jobs ?? []).filter(present)[0]
if (job && ['SUCCEEDED', 'FAILED', 'CANCELED'].includes(job.state ?? '')) {
if (job.state !== 'SUCCEEDED') {
throw new Error(`job ${id} ended ${job.state}`)
}
return job
}
if (Date.now() > deadline) {
throw new Error(`job ${id} still ${job?.state} after ${timeoutMs}ms`)
}
await new Promise(r => setTimeout(r, 3000))
}
}
// --- state: pull everything that is in effect onto disk ---------------------
export async function state() {
mkdirSync(local('.mapped/types'), { recursive: true })
mkdirSync(local('rules'), { recursive: true })
const namespaces = []
for (const t of await sourceTypes()) {
const st = t.sourceType
if (!st) {
continue
}
writeFileSync(local(`.mapped/types/${st}.d.ts`), await declarationTypes(st))
const [lastImport, lastMerge, staged, merged] = await Promise.all([
latestJob('importEntities', st),
latestJob('mergeEntities', st),
count(st),
count(st, { mergingState: { eq: 'MERGED' } }),
])
// The rule in effect lives on the last merge job. Restore it only when
// there is no local file — never clobber work in progress.
const rulesFile = local(`rules/${st}.ts`)
const storedTs = lastMerge?.params?.rules?.typescript as string | undefined
let restored = false
if (storedTs && !existsSync(rulesFile)) {
writeFileSync(rulesFile, unb64(storedTs))
restored = true
}
namespaces.push({
sourceType: st,
baseTypes: t.baseTypes,
staged,
merged,
lastImport: lastImport
? {
id: lastImport.id,
state: lastImport.state,
completedAt: lastImport.completedAt,
params: lastImport.params?.params ?? {},
}
: null,
lastMerge: lastMerge
? {
id: lastMerge.id,
state: lastMerge.state,
completedAt: lastMerge.completedAt,
commitMessage: lastMerge.params?.commitMessage,
rulesFile: storedTs ? `rules/${st}.ts` : null,
restored,
}
: null,
typesFile: `.mapped/types/${st}.d.ts`,
})
}
const snapshot = { connectorId: CONNECTOR_ID, fetchedAt: new Date().toISOString(), namespaces }
writeFileSync(local('.mapped/state.json'), JSON.stringify(snapshot, null, 2))
return snapshot
}
// --- CLI --------------------------------------------------------------------
const [cmd, ...rest] = process.argv.slice(2)
const show = (v: unknown) => console.log(JSON.stringify(v, null, 2))
if (cmd === 'schema') {
show(await schema())
}
if (cmd === 'state') {
show(await state())
}
if (cmd === 'types') {
show(await sourceTypes())
}
if (cmd === 'import') {
show(await importEntities(rest[0]))
}
if (cmd === 'preview') {
const p = await preview(rest[0])
show(p.counts)
for (const e of p.entities) {
const pv = e.context?.__preview ?? {}
console.log(
e.refId,
pv.tags?.join(',') ?? '',
pv.selectable === false ? `BLOCKED(${pv.missingRequired})` : '',
pv.rulesError ? `RULE_ERROR(${pv.rulesError.message})` : '',
)
}
}
if (cmd === 'save') {
const args = rest.filter(a => !a.startsWith('--'))
if (!rest.includes('--confirm')) {
throw new Error('refusing to save without --confirm — preview first')
}
show(await save(undefined, args[0], []))
}
5. Use the same operating loop every time
Shell - recommended loop
npm run schema && npm run codegen # once, and after any platform release
npm run state # what is in effect right now → .mapped/ + rules/
$EDITOR rules/space.ts # change the rule
npm run check # tsc — catches a wrong slug before the server does
npm run preview # counts + blocked rows + rule errors, saved to runs/
npm run save -- "REQ-4821 — space names for building 5138" --confirm
npm run state # the new job is now the one holding the rule
How can an AI coding agent use this workflow safely?
An AI coding agent can be useful for repetitive analysis and rule authoring because the harness makes the important state explicit on disk. Keep the agent focused on read, typecheck, and Preview operations, and keep Save behind a human confirmation.
The generated schema, operation types, namespace declarations, saved rule source, and Preview history are what make this workflow reliable. The agent should read those files instead of guessing field names or API shapes.
Recommended operating instructions for an agent
- **Run state first. **Read the live source types, latest jobs, row counts, and the currently saved rule before proposing changes.
- **Use generated types. **Read .mapped/types/<sourceType>.d.ts before writing a Merge Rule. Do not invent field slugs or context keys.
- **Typecheck every edit. **Do not Preview a rule that fails the TypeScript check.
- **Preview every proposed Save. **Review counts, examples, blocked rows, duplicate unique values, and runtime rule errors.
- **Stop on unexpected removals. **If deleted/removed counts are non-zero and the request did not include removals, inspect those rows before continuing.
- **Do not Import just to filter. **Import changes the staged source snapshot. Use list/Preview filters or a client-side join for analysis.
- **Require explicit approval before Save. **The agent can iterate on state, code, and Preview; a person should authorize the commit. For teams that want a reusable agent policy file, the original HTML guide contains a longer standing-instructions example. The shorter rules above are the same core operating model in the tone used by the rest of the Entities documentation.
API quick reference
| Operation | Kind | Writes? | Equivalent UI behavior |
|---|---|---|---|
| listConnectorEntitySourceTypes | query | No | Source-type sections |
| jobs | query | No | Job progress, recent history, saved rule lookup |
| getConnectorEntityRulesDeclarationTypes | query | No | Rules tab types.d.ts |
| listConnectorEntities | query | No | Grid at rest |
| importConnectorEntities | mutation | Staging only | Import |
| previewConnectorEntities | mutation | No entity commit | Grid preview, counts, validation |
| mergeConnectorEntities | mutation | Yes | Save |
The live schema explorer is available at api.mapped.com/docs. Use the generated schema or the live explorer rather than relying on remembered field names.
What do typed field values look like on the wire?
ConnectorEntityFieldValueInput examples
{ "name": "Conference 12-04" } // NAME — every namespace
{ "floorLevel": 12 } // FLOOR_LEVEL — floor
{ "spaceCode": "12-04" } // SPACE_CODE — space
{ "subBuildingCode": "TOWER-A" } // SUB_BUILDING_CODE
{ "postalAddress": "702 SW 8th St, Bentonville AR" } // POSTAL_ADDRESS — building
// ENTITY_REF — one of three identifier forms, stored and honoured verbatim.
// On nuvolo-source: space.floor → FLOOR, floor.building → BUILDING |
// SUB_BUILDING, building.site → SITE.
{ "entityRef": { "type": "FLOOR", "mappedId": "FLOR…" } }
{ "entityRef": { "type": "FLOOR",
"mappingKey": "msrc://CON…@nuvolo-source/floor/MKEY…?refId=a1b2…" } }
{ "entityRef": { "type": "FLOOR", "identity": {
"entityType": "FLOOR_LEVEL_IDENTITY", "scope": "BUILDING",
"scopeId": "BLDG…", "value": "12" } } }
For ENTITY_REF fields, mappedId, mappingKey, and identity are three ways to identify the target place. Use exactly the identifier form supported by the value you have and the target type allowed by that field.
Common errors
| Code / condition | Typical cause | What to do |
|---|---|---|
| FAILED_PRECONDITION | A job is already running, or the Save would be a no-op. | Poll the running job or accept that nothing would change. |
| INVALID_ARGUMENT | Missing namespace input or a required value is empty for a row being included. | Fix the payload and inspect missingRequired in Preview. |
| PERMISSION_DENIED | The PAT user does not have the required connector access. | Use a PAT associated with a user that has the correct role. |
| NOT_FOUND | The connector ID does not exist or is no longer available. | Re-read the connector list. |
| UNIMPLEMENTED | A Preview-only searchQuery was sent to listConnectorEntities. | Use searchQuery in Preview; use supported list filters for listConnectorEntities. |
| rulesError scope:'rule' | The whole rule failed, commonly because non-transpiled TypeScript was sent as JavaScript or module initialization threw. | Transpile before sending and inspect the rule body in Preview. |
Recommended practices
- Run state first so you know what is already in effect.
- Preview the exact payload you intend to Save.
- Send both the stored TypeScript and executable JavaScript representations of a Merge Rule.
- Treat Import as a source snapshot. Do not narrow it simply to filter the rows you want to work with.
- Read removed/deleted counts before every Save and investigate unexpected rows.
- Allow only one write job per namespace and poll it to completion.
- Treat namespaces as independent and entity references as links to places, not ownership by one connector.
- Use generated declarations and schema types instead of guessing field names or API shapes.
SUPPORT HANDOFF: When escalating a namespace-specific issue, include the connectorId, sourceType, latest Import/Save job state, Preview counts, and examples of any blocked rows or rulesError values. That is normally enough context to continue troubleshooting without recreating the entire session.