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.
| Resource | Value |
|---|---|
| Web app | mobi.theassetguardian.com (public, Vercel-hosted) |
| Data | Business Central — API path verosoftdesign/tag/v1.0 |
| Environment | DEV during the pilot |
| Implementation lead | Kyle 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.
| Entity | Rough count | Note |
|---|---|---|
| Equipment | 52 | The full asset register — buildings, machines, material handling |
| Work procedures | ~558 | The real PM library |
| Work-order templates | ~222 | Almost entirely Line 6 (THERMAL / PREGRIT / POSTGRIT / PACK) |
PM policies (DateMeterPolicy) | ~152 | Concentrated on three machines: 2358, 1168, 2271 |
| Meter readings | 0 | All 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.
equipmentIdis 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/equipmentSubgroupclassify:MACH,MHE,FAC,PLINE.- Line 6 is the only production line modelled to depth (
LINE6with 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”.
| Slot | PSI meaning | Notes |
|---|---|---|
field17 | QR sticker code (EQ-1168) | Max 20 chars |
field18 | AFTEC 92,000-series work order | See below |
text1 | Full QR label URL | Max 80 chars |
A caption cannot be renamed through the API
updateAssetLabelCaptionraises 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
field18are the 2025 work orders.reconcileprints 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.
- Asset column — direct
equipmentIdmatch (most rows). The cell sometimes holds several assets ("3060 & 2695", one hilo across a re-numbering), so it is compared token-wise. - Description text — Line 6 and waterjet rows put the number in the text (“Maintenance 4 Waterjet - 1168” → WO 92802).
- Legacy cross-reference — the
[Asset #: NNNN]in TAG’s own description (asset 3060 → legacy 2695 → WO 92792). - Building R&M —
BLDG4→ “Bldg 4 R&M (misc, facility supplies, etc)”.
Three matching traps, each already paid for
- Never substring-match. Asset
46appears inside9246,1468and most dates.N/Ais 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-SCR02are the known pair. - Unmatched assets — no standing WO exists yet (Line 6 machines
2271–2274,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 built — src/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 field | Comes from |
|---|---|
workOrderNumber | TAG equipmentId → field18 |
laborHours | TAG PostedTimesheet.actualTime |
weekDate | resultDatetime (else startingDatetime), rounded back to Monday in UTC |
generalLedgerNumber | the workbook’s gl acct/gl cat labor column, per WO |
empNo | the 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 stores0712The 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
employeeIdis 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/timesheetsreplaces the employee's week — it does not appendThe body carries
oldLineCountandupdLock, and the header carriesregularHours,vacationHoursandsickHours. 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
updLockjust 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:
| Connector | Purpose |
|---|---|
| Mobi OData API | The CMMS entities — Equipment, Request, WorkOrder, Personnel, Attachment, … |
| Mobi IoT | Device health, live data, alerts, thresholds |
Gotchas that cost real time
execute_toolrequiresconnectorInstanceId. Without it you getunknown_operation, which reads like a missing tool but is a missing argument.- Every write needs
input.confirmExecution: true, or you getconfirmation_required. That is a gate on the call, not on the key. - Don’t infer key scope from the sandbox connector.
ui_generatereturnstool_blockedandui_builder_handoffreturnstool_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, notAsset.listAssetdoes 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. Checkingres.okalone silently swallows every error. equipmentIdis 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… | Read | Watermark on |
|---|---|---|
| A technician was assigned a work order | listAssignment | systemCreatedAt |
| Someone opened a request | listRequest ∪ listFinishedRequest | systemCreatedAt |
| A work order changed status or priority | listWorkOrder | systemModifiedAt |
Assignment is an append-only table, and that is the good news
Assignmentholds 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 owntechnicianCode/allAssignedTechnicians/assignmentsCountfields 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 inAssignment.
FinishedRequestdoes not mean "completed" — and a request can leaveRequestbetween pollsIt is the archived-document container, and status is preserved on the way in. As of August 2026 four records sit in
FinishedRequeststill carrying statusNEWREQ— opened, never triaged, aged out. A poller watching onlyRequestwill 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
TagSetupand 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
nowhen creating a Request. The connector schema marks it required; the tenant disagrees and assigns from theTAG-REQnumber series. Supplying it client-side races whenever two people report the same broken machine — the normal case, not the edge case. priorityRankis the field TAG’s own UI labels “Priority” (P0 - Critical…P3 - Priority 3). There is a separate legacypriorityenum thatTagSetup.defaultWrkRequestPriorityforces toCriticalfor 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.
Related
- 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