Skip to content

Management Studio REST API (export / import / workflow instances)

The Management Studio SPA (the Angular app at …/ManagementStudioPrtyDev) is backed by a plain REST API. The endpoints were reverse-engineered from the app's own JS bundle (main.js) using the authenticated session from export_from_management_studio.py. This page documents the three surfaces we drive programmatically and the Python tools that wrap them, so agents can import packages and read workflow run errors without screenshots.

All calls reuse the same auth/session as the export tool (.env MS_USERNAME/MS_PASSWORD, auto-login, x-xsrf-token). Base URL: https://testmobile.lincolnshirehp.com/ManagementStudioPrtyDev.

Response envelope: success responses are {StatusCode, Value, …} and the SPA unwraps to .Value (replicated by unwrap() in both tools). Errors are {StatusCode, Message, Warning} or {Detail} / problem-details. A wrong endpoint path returns the SPA's index HTML with HTTP 200 — never treat a non-JSON 200 as success.


1. Export (export_from_management_studio.py)

GetAll* list endpoints → api/RefData/PrepareExportPackage (base64 payload) → api/RefData/DownloadExportPackage. Per-category packages (a combined ~60 MB package 504s during assembly). See deployment/dev-to-live.md and §15 of CLAUDE.md.


2. Import (ms_import.py) — a 3-call flow

versaa-rag\.venv\Scripts\python.exe versaa-rag\ms_import.py <package.ftpackage>
  --inspect-only   # upload + show manifest, do NOT import (safe preview)
  --activate       # import AND activate immediately (default: import as INACTIVE/draft)
  --clear          # cancel a staged import on the server
  1. Upload — tus 1.0.0 resumable to <BASE_URL>/files. The SPA uses tus-js-client (endpoint:"files", 1 MB chunks, overridePatchMethod). We hand-roll it: POST /files with Tus-Resumable: 1.0.0 + Upload-Length + Upload-Metadata → read the Location header → send bytes via POST <Location> with X-HTTP-Method-Override: PATCH and Upload-Offset. fileId = last path segment of the upload URL (this is the SPA's uploadedPackageId).
  2. InspectPOST api/RefData/UploadImportPackage/?id=<fileId> (null body) → manifest preview, e.g. {Forms:[…], Workflows:[{Name, Version, Comment, …}], …}.
  3. ImportPOST api/RefData/ImportCurrentPackage/?id=<fileId>&activate=<True|False> (null body) → an array of per-item results:
    { "Successful": true, "ErrorMessage": null, "ItemName": "Unvented Hot Water Storage", "ItemType": "Form" }
    
    Cancel instead: POST api/RefData/ClearCurrentImportPackage.

Gotchas

  • Default is activate=False — versions land inactive/draft; nothing goes live until you --activate (or activate deliberately in MS). Safe staging.
  • Forms auto-version on import (MS assigns the next sequential version), so re-importing a form succeeds. Workflows do not — re-importing an existing workflow version returns a generic HTTP 500 ("An unexpected error occurred"); a workflow import must be a new version. Inspect succeeds either way (the package parses); the 500 is in the apply step.
  • Import/activation errors are fully visible in the per-item ErrorMessage. Runtime workflow errors are NOT — see §3.

3. Workflow instances (ms_workflow_errors.py) — read-only

Lets an agent see workflow runs (the "Workflows → Search Instances → Instance Details" screen) as JSON. This is the only way to see runtime workflow errors — they occur when an instance executes against QL, long after import, and are invisible to the import flow.

versaa-rag\.venv\Scripts\python.exe versaa-rag\ms_workflow_errors.py list   # default: Errored, last 24h
  --status Errored|Running|Complete|any   --since 24h|7d   --workflow "<name>"   --limit N   --json
versaa-rag\.venv\Scripts\python.exe versaa-rag\ms_workflow_errors.py show <instanceId> [--no-xml] [--json]
  • List: GET api/Workflows/GetAllWorkflowInstances (query: workflowName, workflowInstanceStatuses, filterStatusByCategoryStatuses, completedBefore/After, externalId, initialDataValue, startIndex, sortColumn, sortDirection, maxItems). Returns {Items:[…], TotalCount}. Items: Id, WorkflowName, Status, UserStatus, StartedAt, LastTransitionedAt, CompletedAt, ErrorMessage, ExternalId, Tags. NB errored instances have no CompletedAt (0001-01-01… placeholder) so the tool sorts/filters on StartedAt. workflowInstanceStatuses is mandatory — the API 500s with "WorkflowInstanceStatuses has to be set to perform a search" if omitted, so --status any sends the full enum, not nothing.
  • Detail: GET api/Workflows/GetWorkflowInstanceInfo?workflowInstanceId=<id>&includeTasks=true{ WorkflowInstanceInfo:{…, InitialData:"<Event…> Task XML", ErrorMessage, WorkflowVersion}, WorkflowHistory:[{Name, TypeName, Status, Result, RunCount, LastError, …}], WorkflowTasks, … }. show prints the errored step, full exception/stack, step history, and the initial Task XML.

Status enum

Running, Errored, Complete, Aborting, Aborted, Cancelling, Cancelled, PendingEvent, PendingRetry, PendingWorkflowInstance, AbortedWithError, RollbackPendingRetry.

Mutation actions — ms_workflow_actions.py (WRITE companion)

The viewer (§3) stays read-only; instance mutations live in a separate tool:

versaa-rag\.venv\Scripts\python.exe versaa-rag\ms_workflow_actions.py retry|abort|cancel <instanceId> [--yes]
- retryGET api/Workflows/RetryCurrentStep?workflowInstanceId=<id> (re-run the current/errored step) - abortPOST api/Workflows/AbortWorkflowInstance?workflowInstanceId=<id> (hard stop → Aborted) - cancelPOST api/Workflows/CancelWorkflowInstance?workflowInstanceId=<id> (cancel + rollback)

Without --yes it only previews (prints current state); --yes fires the live Dev mutation, then re-reads the state. Abort/cancel are async — the instance transitions (Errored/RunningAbortingAborted) a moment after the call returns True. (Verified 18/06/2026: aborted two errored test instances → Aborted.) UpdateInstanceToActiveVersion exists too but isn't wired. Workflow version activation/rollback uses a different route: POST api/Workflows/SetActiveVersion?workflowId=<id>&version=<n>. See workflows/fix-errors.md for the manual UI path.


4. Trigger a workflow / test a form (ms_submit_form.py)

Tests the form-data → workflow path without completing a form on the Aareon Mobile device: trigger a workflow on Dev from a Task/Event XML, then read the resulting instance's outcome.

versaa-rag\.venv\Scripts\python.exe versaa-rag\ms_submit_form.py --from-instance <id> [--dry-run]
  ... --xml <file.xml> --workflow "<name>" [--external-id X] [--no-watch] [--watch-timeout N] [--json]
  • Trigger: POST /api/Workflows/StartWorkflow body {WorkflowName, ExternalId, InitialData} (the <Event…><Task…>… XML). Response is the new instance Id (the SPA navigates to its details page with it). The tool also tags a unique ExternalId and can locate the instance by it via GetAllWorkflowInstances?externalId= if needed, then renders the outcome via the §3 viewer.
  • Modes: --from-instance captures WorkflowName + InitialData from an existing instance and replays it; --xml+--workflow submits a hand-built/edited Task XML.
  • --dry-run prints the exact payload and posts nothing — the approval gate before any live run.

Gotchas / safety

  • LIVE: a non---dry-run run executes a REAL workflow on Dev (QL writes, emails, document generation, DW writes). Always --dry-run first; pick a side-effect-light workflow.
  • StartWorkflow starts a NAMED workflow directly — it bypasses the device's event-routing and Task.Task persistence. Great for testing one workflow against specific form data; for the fully faithful device path use the deferred WCF SOAP route (IdentityService/UserLoginITaskQueueService/SendTaskEventData at testmobile.lincolnshirehp.com:9006/PrtyDev, namespace http://schemas.1sttouch.com/2009/01/Mobile/).
  • An invalid WorkflowName returns a clean 500 "Workflow '<name>' does not exist" and runs nothing — handy plumbing check.
  • Verified (18/06/2026): triggering IS Test Survey Completion created instance 51a4e432-… and the tool read its live PendingRetry/step-error back — proving the submit→run→read loop.

5. Workflow briefing — structure + runtime (ms_workflow_info.py) — read-only

"Learn a workflow before you change the form/workflow that touches it." Given a name, prints its structure (trigger messages, ordered steps with type + key params, and each step's transitions incl. XPath branch conditions) plus a runtime summary (status breakdown + recent instances, with an optional drill-in). Structure is read live, falling back to the locally-extracted .workflow XML (clearly flagged) when MS is unreachable.

versaa-rag\.venv\Scripts\python.exe versaa-rag\ms_workflow_info.py "<name>"
  --structure-only | --runtime-only   --version active|latest|<N>   (default active)
  --local            # force local extracted XML, skip MS
  --runs N           # recent instances to summarise (default 10)
  --sample errored|latest   # also drill into one instance (reuses the §3 viewer)
  --json
  • Resolve: GET api/RefData/GetWorkflowsWithVersions[{Info:{Id,Name,ActiveVersion, LatestVersion,…}, Versions:[…]}]. Name match is case-insensitive (exact first, else substring); a partial name matching >1 workflow lists candidates with their active/latest versions instead of guessing.
  • Structure (live): GET api/WorkflowEditor/GetLatestWorkflowDefinition?workflowId=<id> or GET api/WorkflowEditor/GetWorkflowDefinition?workflowId=<id>&version=<n>&lockForEdit=false{Id,Name,Description,Steps:[{Id,Name,Type,Parameters:[{Name,Value,Type}], Transition:{GatewayType,Targets:[{TargetId,Type,Label,Expression,Value}]}}], Starts:[{Type: ManualStart|MessageStart, MessageName}], Ends:[…]}. The JSON mirrors the on-disk .workflow XML 1:1, so one normaliser renders either source.
  • Structure (local fallback): newest extracted/workflows/Workflows/<Name>_<ver>.workflow (spaces→+ in the filename). Prints a [fallback: local XML, may be stale — last Dev pull …] banner so a stale read is never mistaken for live.
  • Runtime: reuses §3's GetAllWorkflowInstances (status breakdown + recent runs) and, with --sample, GetWorkflowInstanceInfo rendered by the §3 viewer. Remember completed instances are cleared, so a healthy workflow often legitimately shows no instances.
  • Read-only: no Save/SetActiveVersion/Start endpoints are wired (those live in §3's ms_workflow_actions.py / §4's ms_submit_form.py).

6. Users / accounts / properties (ms_user_info.py) — read-only

Answers the "duplicate accounts / which operative sees which jobs" class of ticket. Lists a person's account(s), shows one account's user-properties, and lists the property definitions.

versaa-rag\.venv\Scripts\python.exe versaa-rag\ms_user_info.py find "Walker"     # accounts for a person (flags duplicates)
  ... show "LINCOLNSHIREHP\WalkerA"      # one account's user-properties + capabilities
  ... props                              # the configurable property definitions
  ... find "Walker" --env live --i-understand-live   # real operative data lives in LIVE
  • List: GET api/Users/GetUsers[{DisplayName, UserName, Properties:[…], Capabilities:{}}]. Properties is empty here (GetUsers does NOT inline property values) and it ignores search/paging params, so filtering is client-side. Not capped at a small number (Live returns all ~406 users); still verify counts with --json.
  • Per-user detail (property VALUES): GET api/Users/GetUser?id=<UserName> (the SPA's GetUserDetails; param is id = the UserName, e.g. LINCOLNSHIREHP\WalkeA) → {UserName, DisplayName, Properties:[{Name,Value}], Groups, AssignedPermissions, IsEnabled, IsLockedOut, …}. show uses this to print real values + enabled/locked + groups.
  • Property definitions: GET api/Users/GetConfigureProperties → 33 definitions. There is no literal "Work Team" property — job visibility is driven by Appointment Pools, DiaryResource/DiaryResourceGroup, Operative Trades (and Aareon_OperativeID/QL User Id). ("Work Team" in it/appointment-polling.md is shorthand for these.)
  • Real operative property values live in Live (Dev operative accounts are unconfigured), so diagnosing a visibility ticket means a read-only --env live lookup. Live MS web UI is on mobile.lincolnshirehp.com (not testmobile) and needs Live web access (1TPG_PrtyLive_Access_*).
  • Full Users route map (from main.js): read Get{Users,User,UsersWithGroupSupport, UserListWithAccessSystemPermission,Groups,GroupUsers,Permissions,ConfigureProperties}; write (NOT wired) {Add,Delete,Enable}User, UpdateUserProperty, Set*Permissions, *Group*.

7. Read-only SQL (ms_sql.py)

Run SELECTs against the Versaa/QL databases directly — no SSMS round-trip. Ships the sql/cheat-sheet.md queries as named presets plus ad-hoc --sql/-f.

# Launch via the runas helper so the connection authenticates as the SQL admin account:
versaa-rag\run_as_sql_admin.ps1 whoami
versaa-rag\run_as_sql_admin.ps1 tasks-by-user --user WALKERA
versaa-rag\run_as_sql_admin.ps1 tasks-by-form --form "Fire Risk Assessment"
versaa-rag\run_as_sql_admin.ps1 --db ql contact-check --prop B23546
versaa-rag\run_as_sql_admin.ps1 tables --like hpm1st        # schema discovery
versaa-rag\run_as_sql_admin.ps1 columns --table hpm1stoa
versaa-rag\run_as_sql_admin.ps1 --sql "SELECT TOP 5 name FROM sys.databases" --json
  • Auth = Windows / integrated (SSMS connects as LINCOLNSHIREHP\adminis). pyodbc uses the process identity, so run_as_sql_admin.ps1 does runas /netonly /user:LINCOLNSHIREHP\adminis (password prompted, never stored) and then runs the tool. Running ms_sql.py directly uses your login (LINCOLNSHIREHP\SaleeI), which gets Login failed (18456) on these servers.
  • Driver: "ODBC Driver 18 for SQL Server" (auto-picked); Driver 18 → Encrypt=yes; TrustServerCertificate=yes (internal hosts have no trusted cert).
  • Env→DB map (--env, default dev; non-dev needs --i-understand-live): dev = lhp-sql02 / Versaa_PrtyDev (+ a_qlfdat via --db ql); live = aareon-sql01 / 1stTouch_PrtyLive (+ QLFDAT). --db takes versaa/ql/an explicit catalog.
  • Read-only guard: every statement must start SELECT/WITH; INSERT/UPDATE/DELETE/DROP/ ALTER/EXEC/… and multi-statement injection are rejected before connecting. Output: aligned table (default), --json, --csv.

8. Ticket intake (halo_ticket.py + extract_ticket.py)

  • extract_ticket.py <N> now reads a scratch/halo_tickets/ticket_<N>/ folder containing mixed files, and supports .eml emails (stdlib email: base64/quoted-printable decode, text/plain preferred else HTML→text, attachments extracted) and standalone images — on top of DOCX/ODT/PDF. (Added because ticket threads arrive as .eml; no more hand-decoding base64.)
  • halo_ticket.py drives your authenticated Halo web session with Playwright (LHP has no Halo API). Persistent profile (versaa-rag/.halo_profile, git-ignored) → sign in once incl. MFA:
    python versaa-rag/halo_ticket.py login          # one-time, headed
    python versaa-rag/halo_ticket.py fetch 20472     # headless: saves page text/PDF/PNG + attachments, then runs extract_ticket
    
    Needs HALO_BASE_URL=https://lhp-servicehub.haloitsm.com + HALO_TICKET_PATH=tickets?id={n} in .env. Read-only (navigates/screenshots/downloads only). Halo renders the ticket conversation inside an iframe (the main frame only has the queue chrome + header), so the tool reads inner_text from every frame and concatenates — that's what captures the message bodies. The full-page PDF/PNG always capture the ticket too (the screenshot is a reliable fallback). Verified end-to-end on ticket 20472.

Discovering more endpoints

versaa-rag/tools/discover_import_endpoint.py logs in, downloads the SPA bundles to versaa-rag/tools/_ms_js/, and greps them for api/… routes. Re-run it to map new surfaces (the file is one minified line — use char-offset context windows, not grep -C).