TAG Mobi

The Asset Guardian (TAG) is PSI’s CMMS/EAM — the system maintenance uses to hold the asset register, raise work orders, run preventive-maintenance policies, and record labour against equipment. It is a Verosoft product that sits on top of Business Central, so TAG data lives in BC tables and is reachable through BC’s API surface.

ResourceValue
Web appmobi.theassetguardian.com (public, Vercel-hosted)
DataBusiness Central — API path verosoftdesign/tag/v1.0
EnvironmentDEV during the pilot
Implementation leadKyle Gentz (KGENTZ), with the TAG/Verosoft PM

Pilot state

TAG is in implementation, not production. As of August 2026 the tenant holds a real, loaded asset register with PM content authored against a small number of machines.

EntityRough countNote
Equipment52The full asset register — buildings, machines, material handling
Work procedures~558The real PM library
Work-order templates~222Almost entirely Line 6 (THERMAL / PREGRIT / POSTGRIT / PACK)
PM policies (DateMeterPolicy)~152Concentrated on three machines: 2358, 1168, 2271
Meter readings0All PM policies are date-driven, none meter-driven

Read this as: the asset register is solid and the PM library is substantial, but scheduled maintenance is live on a handful of machines. Most material-handling equipment — forklifts, reach trucks, lifts, the assets carrying statutory inspection duties — has no PM schedule yet.

Rebuild these numbers rather than trusting them; they age. See §Access below.


Asset model

Assets are Equipment records with a hierarchy: enterprise → facility → area → equipment, plus a level and a masterId/parent.

  • equipmentId is the business key (1168, 2358, MH-PJ01) and is what PSI has historically called the asset number — the same numbers used in the AFTEC 92,000-series spreadsheet.
  • equipmentGroup / equipmentSubgroup classify: MACH, MHE, FAC, PLINE.
  • Line 6 is the only production line modelled to depth (LINE6 with the grit/thermal/pack machines as children).
  • Legacy identifiers are carried in the description text as [Asset #: NNNN | Equip #: N] — worth knowing, because that bracket is sometimes the only link back to older PSI records.

Custom fields, and what PSI uses them for

TAG exposes per-equipment-group user-defined slots — field1–48, text1–8, date1–10, checkOff1–20 — and a separate AssetLabelCaption record per equipmentGroup supplies the UI label for each slot. A slot with no caption shows as the generic “Field 17”.

SlotPSI meaningNotes
field17QR sticker code (EQ-1168)Max 20 chars
field18AFTEC 92,000-series work orderSee below
text1Full QR label URLMax 80 chars

A caption cannot be renamed through the API

updateAssetLabelCaption raises a Business Central client callback — “Changing Lookup Setting” — which an API call cannot answer, so the write fails. Captions have to be set in the TAG UI.

Assets also carry attachments, which support isUrl: true. PSI uses that to hang the QR sticker label off each asset as a live URL rather than an uploaded file.


The AFTEC work-order bridge

The problem: until BC go-live, PSI’s cost and labour tracking still lives in AFTEC. Maintenance work recorded in TAG has to end up against the right AFTEC account, or a year of maintenance cost goes unattributed.

AFTEC 92,000-series work orders are annual standing WOs. One per asset (roughly), opened at the start of the year and left open all year to accumulate labour and purchases. The number changes each year; the asset does not.

The mapping lives on the asset

Each TAG asset carries its AFTEC WO in field18. The reconciliation is code, in tag-aftec-bridge — run npm run reconcile for the current picture rather than trusting a count written down here.

The source is the maintained workbook 92,000 SERIES 2026.xlsx — 12 sheets by department, with year columns 2021–2025 where the latest year column holds the current WO. The workbook is kept out of git deliberately: it changes independently of the code, and a stale copy would be worse than none.

The workbook is named 2026 but has no 2026 column

The numbers currently in field18 are the 2025 work orders. reconcile prints the latest year it actually found and flags the mismatch — check that line before believing a run. When 2026 WOs are issued this is a re-run, not a re-design.

Matching needs four routes, because the spreadsheet’s Asset column is populated inconsistently. Every match is reported with the route that found it, because “matched on description text” is a claim a human can check and “matched” is not.

  1. Asset column — direct equipmentId match (most rows). The cell sometimes holds several assets ("3060 & 2695", one hilo across a re-numbering), so it is compared token-wise.
  2. Description text — Line 6 and waterjet rows put the number in the text (“Maintenance 4 Waterjet - 1168” → WO 92802).
  3. Legacy cross-reference — the [Asset #: NNNN] in TAG’s own description (asset 3060 → legacy 2695 → WO 92792).
  4. Building R&MBLDG4“Bldg 4 R&M (misc, facility supplies, etc)”.

Three matching traps, each already paid for

  • Never substring-match. Asset 46 appears inside 9246, 1468 and most dates.
  • N/A is not an identifier. It appears on both sides — treating it as a value matched a pallet jack to “Certification class/training”.
  • A building’s WO is its standing R&M order, not every order naming it. Matching on the building number alone pulled seven candidates for BLDG4 — a forklift parked there, a grit-blast upgrade, a compressor.

Ambiguity is never resolved by picking one. Several candidates means a real question about which cost bucket, and it is left for a person: 2358 splits across WJ Maintenance / WJ Supplies / WaterJet Development and is the one still open.

Two things the report surfaces that are worth acting on:

  • Shared work orders — two assets resolving to one WO usually means TAG holds the same machine twice, under an old number and a new one. Both would post to one AFTEC account and the duplicate would become invisible in the cost. 3160 / MH-SCR02 are the known pair.
  • Unmatched assets — no standing WO exists yet (Line 6 machines 22712274, ARGO-L6, LINE6, and several newer material-handling items), or the workbook lags TAG.

Getting labour back into AFTEC

The intended end state is a batch ingest through PSI.UniData.API, not a spreadsheet export. It is designed but not builtsrc/export-labor.mjs in tag-aftec-bridge holds the full design and refuses to run rather than half-working, because it is blocked on the identity chain below.

AFTEC labour posts as an employee-week, so the shape is fixed by AFTEC, not by TAG:

TimesheetHeader { empNo, weekDate (Monday), costCenter, lines[] }
  └─ TimesheetLine { workOrderNumber, laborHours, generalLedgerNumber, … }
AFTEC fieldComes from
workOrderNumberTAG equipmentIdfield18
laborHoursTAG PostedTimesheet.actualTime
weekDateresultDatetime (else startingDatetime), rounded back to Monday in UTC
generalLedgerNumberthe workbook’s gl acct/gl cat labor column, per WO
empNothe technician — see below

Technician identity: resolve, never store

EmpID is deliberately NOT stored in TAG Mobi. Technicians resolve at ingest time:

TAG technicianCode → Personnel.email → Entra user → employeeId → AFTEC EmpID

This follows the identity principle codified during the AFTEC↔Entra reconciliation: Entra employeeId == AFTEC EmpID is authoritative. One source of truth, and no second copy of an identifier to drift.

TAG’s Personnel table does have an employeeNo field. Leave it empty — populating it would recreate exactly the duplication this avoids.

Entra stores 712; AFTEC stores 0712

The employee number is zero-padded to 4 characters in AFTEC and unpadded in Entra. They look interchangeable and comparing them raw resolves nobody — silently, because “not found” is indistinguishable from “no such person”. Pad before comparing, then confirm the EmpID exists in AFTEC.

Do not work around an unresolved technician by matching on name or the 3-letter userId — AFTEC recycles those across different people, so a wrong match posts one person’s hours to another’s.

Email is the join key, so a Personnel record without an email cannot resolve at all. RES001 / RES100001 are TAG resource codes rather than people — “Lou Hum” under RES100001 is a vendor/implementation resource, present in neither Entra nor AFTEC, and must never post.

The AFTEC employee list, including EmpID, is available unauthenticated from the internal network: GET api.progressivesurface.com/api/redbook/dev/lookups/employees — EmpID is returned as code.

A blank lookup is not evidence of blank data

This chain was once recorded as blocked on “Entra employeeId is blank for the TAG technicians”. It never was — the values have been populated all along. A lookup had failed for an environment reason and the failure was written down as a directory fact. Treat a Graph 404 as an answer and every other status as “we could not ask”.

Posting into AFTEC is a full-week replace

POST /api/timesheets replaces the employee's week — it does not append

The body carries oldLineCount and updLock, and the header carries regularHours, vacationHours and sickHours. Posting a header containing only maintenance lines wipes the employee’s regular hours, vacation, and every line anyone else put there.

Every post must be read-modify-write: GET the week, merge into its existing lines, POST back with the updLock just read. Match lines on work order, or a re-run doubles the hours — AFTEC has no natural key that would stop it.

Auth is Entra delegated, and the API resolves the caller’s on-prem AD identity. A cloud-only admin (progadmin) gets HTTP 403 with a perfectly valid token. The ingest must run as a real AD user — which is why it is an operator tool and not an Azure job. /api/timesheets/dev targets the sandbox AFTEC server and is where anything new should be proven first.

The UniData API runs as the caller's on-prem AD user

A cloud-only admin (progadmin) gets HTTP 403 from the timesheets API. The ingest must run as an account with an on-prem AD identity.


Access

TAG is reachable programmatically through the Mobi connector platform, registered as the mobi MCP server (endpoint mobi.theassetguardian.com/api/mcp-ext/mcp, bearer API key).

It exposes meta-tools rather than one tool per entity — find_tools, get_tool_schema, execute_tool, list_installed_connectors. Two connectors matter:

ConnectorPurpose
Mobi OData APIThe CMMS entities — Equipment, Request, WorkOrder, Personnel, Attachment, …
Mobi IoTDevice health, live data, alerts, thresholds

Gotchas that cost real time

  • execute_tool requires connectorInstanceId. Without it you get unknown_operation, which reads like a missing tool but is a missing argument.
  • Every write needs input.confirmExecution: true, or you get confirmation_required. That is a gate on the call, not on the key.
  • Don’t infer key scope from the sandbox connector. ui_generate returns tool_blocked and ui_builder_handoff returns tool_not_read (“is not read-only”) — both are that connector’s restrictions and look exactly like a read-only API key. Probe the OData connector instead.
  • The entity is Equipment, not Asset. listAsset does not exist; “asset” is marketing vocabulary.
  • A business failure still returns HTTP 200. Results are wrapped three deep — HTTP status, a JSON-RPC envelope, then { ok, data | error } inside an SSE frame. Checking res.ok alone silently swallows every error.
  • equipmentId is capped at 20 characters.

Notifications — what TAG can and cannot tell you

Short version: TAG Mobi pushes nothing. There is no webhook, subscription or callback registration anywhere in the Mobi connector surface — the only tools matching “trigger” belong to the IoT connector and fire on device telemetry, not on work orders or requests. Anything that reacts to a TAG event has to discover it.

Discovery is cheap, because every TAG entity carries systemCreatedAt and systemModifiedAt and both are filterable through the connector. A watermarked poll is the mechanism.

You want to know…ReadWatermark on
A technician was assigned a work orderlistAssignmentsystemCreatedAt
Someone opened a requestlistRequest listFinishedRequestsystemCreatedAt
A work order changed status or prioritylistWorkOrdersystemModifiedAt

Assignment is an append-only table, and that is the good news

Assignment holds one row per technician per document (documentNo, technicianCode, startDatetime, isClockedIn). A new assignment is a new row, so assignment detection is a watermarked read of an insert-only log rather than a diff over work-order state. The WO’s own technicianCode / allAssignedTechnicians / assignmentsCount fields are the right thing to render and the wrong thing to detect from — a technician swapped A→B→A between two polls looks unchanged on the header and shows three rows in Assignment.

FinishedRequest does not mean "completed" — and a request can leave Request between polls

It is the archived-document container, and status is preserved on the way in. As of August 2026 four records sit in FinishedRequest still carrying status NEWREQ — opened, never triaged, aged out. A poller watching only Request will silently miss requests. Union both entities on the same watermark.

TAG’s own escalation engine

Request carries sendNotification, escalationLevel, escalationChangeDateTime, escalationMessage, durationSinceCreateHrs, technicianFirstAssigned / firstTechAssignedDuration and their ...LastAssigned counterparts; OrderType carries maxNotificationCount. This is TAG’s native escalation ladder. Three things follow:

  • It delivers by email, not Teams.
  • It is configured in the TAG UI — escalation setup is not on TagSetup and is not exposed as an entity.
  • It is a useful second layer: the safety net that fires when nobody responds. Any Teams integration is the fast path that stops it firing, not a replacement.

Never write those fields from an integration. They are TAG’s state machine — read them, let TAG own them.

The one real push option

TAG is a BC extension, so its API pages are ordinary Business Central API pages and BC supports webhook subscriptions on them. That is genuine push, and it costs: an Entra app registration, a BC application user, a scoped permission set, a secret to rotate, a publicly reachable endpoint that answers BC’s validation handshake, and renewal before a short expiry. The notification carries no payload — you still read the record back, so the read path is identical either way. Treat it as an optimisation of a working poller, never as the foundation of an unbuilt one.

The design that builds on all of this: psi-notify-bot/docs/maintenance-bot-plan.md.


Request and work-order behaviour

Two tenant behaviours worth knowing before writing anything:

  • Never send no when creating a Request. The connector schema marks it required; the tenant disagrees and assigns from the TAG-REQ number series. Supplying it client-side races whenever two people report the same broken machine — the normal case, not the edge case.
  • priorityRank is the field TAG’s own UI labels “Priority” (P0 - CriticalP3 - Priority 3). There is a separate legacy priority enum that TagSetup.defaultWrkRequestPriority forces to Critical for every request, including hand-created ones — so every printed Work Request reads Priority: Critical. That is a tenant setting to fix in TAG, not something an integration should work around by writing the legacy field.

  • tag-request-form — the public QR-scan request form that files into TAG
  • tag-aftec-bridge — the work-order reconciliation and (pending) labour ingest
  • procisely — QR stickers and label printing for assets
  • unidata-api — the AFTEC gateway the batch ingest will use
  • psi-iot — the machine-telemetry initiative that the Mobi IoT connector would serve