Files
zeus-skills/ops/connectwise/connectwise-psa-reporting/SKILL.md
T

20 KiB

name, description
name description
connectwise-psa-reporting Workflows for querying, reporting, updating, and scheduling ConnectWise PSA tickets — handling legacy mirror bypass, authentication masking, ticket mutations, and schedule entry creation.

ConnectWise PSA Reporting & Scheduling

Overview

This skill governs the retrieval, reporting, and mutation of ticket data from the ConnectWise PSA API, with specific focus on bypassing environment-specific routing issues (legacy mirrors) and handling secure credential retrieval within the Hermes runtime. It also covers schedule entry creation, since that is the correct way to assign dates to tickets.

Core Workflow

1. Authentication & Setup

To avoid "credential masking" (where the runtime truncates secrets in prompts), never hardcode API keys in prompts or scripts.

  • Reference Data: Use references/connectwise_reference.md for the Base URL, Client ID, and Reporting Conventions.
  • Secrets: Store the Company ID, Public Key, and Private Key in a secure file (e.g., ~/.secrets/connectwise).
  • Execution: The agent or script must read these files at runtime to construct the Basic Auth header: base64([CompanyID][PublicKey]:[PrivateKey]).
    • Secret file format: ~/.secrets/connectwise is a KEY=value file with vendor-prefixed keys (CW_COMPANY_ID, CW_PUBLIC_KEY, CW_PRIVATE_KEY). Parse defensively — normalize key names case-insensitively, ignore _/- and strip vendor prefixes (CW_) so any spelling resolves. Hardcoding one exact spelling produces empty lookups and a misleading 401.
    • Parse sanity before retrying auth: on 401 Unauthorized, print the parsed key NAMES and value lengths before changing any code — a key-name mismatch and a genuinely bad credential produce identical HTTP errors but different fixes. See references/api_quirks.md for the proven build.
    • The + Logic: the + is part of the PublicKey value on this instance. Build the pre-encoded string as companyid + publickey + ":" + privatekey by plain concatenation (e.g. VNI+rt...:priv...) — do not insert a second + (avoid VNI++rt...).
  • Member identity: /system/myMember returns 404 for API members on this instance. Resolve the acting member with GET /system/members?conditions=identifier='<memberIdentifier>' (URL-encode the quotes) and reuse the returned id in every owner-scoped query and schedule POST (owner/id=<memberId>, member/id=<memberId>; resolve any other member the same way).
    • Preferred Method: Use the n8n MCP server for ConnectWise data retrieval whenever possible to avoid the fragility of raw API calls and the legacy mirror bug.

2. Bypassing the Legacy Mirror

In certain environments, general list requests to /service/tickets route to a legacy data mirror (returning stale data from ~2011).

  • The Fix: You MUST use the conditions parameter to target production IDs (e.g., conditions=id>=110000). Without this, the API will return no results or outdated data from 2011.
  • Critical Update: Only use the id>=110000 filter if specifically targeting a subset of newer tickets. For comprehensive reporting (e.g., finding all unowned tickets), omit this condition, as it will filter out legitimate open tickets with lower IDs.
  • The "Mirror Bug": For comprehensive reporting (e.g., finding all unowned tickets), omit this condition, as it will filter out legitimate open tickets with lower IDs.
  • Verification: If the returned tickets are from 2011, or the list is unexpectedly empty despite known active tickets, a conditions filter is required.
  • Server-Side Crashes: Be aware that the /service/tickets endpoint can throw a 500 Internal Server Error with the message "Length cannot be less than zero. Parameter name: length". This is a .NET internal Substring() exception typically caused by malformed data in a specific record. It is generally not solvable by adjusting request parameters (like pageSize) and requires ConnectWise support to resolve.
  • Owner Logic: For ownership filtering, check both the owner.identifier field and the resources string (comma-separated list), as tickets may be assigned to multiple members.

3. "Open Tickets" Definition

For VistaNet reporting, a ticket is considered "Open" only if it meets all these criteria:

  • closedFlag == false
  • Status name does NOT start with > (e.g., exclude >Closed, >Completed)
  • Status name is NOT exactly "Closed"
  • dateResolved is null/absent.

Implementation Patterns

LLM-Driven Reports (Cron Jobs)

When using an agent for a recurring report:

  • Prompt the agent to read the reference file and secrets file first.
  • Explicitly command the use of conditions and a large pageSize (e.g., 1000) to ensure production data is captured.

For high-reliability reporting, move the logic into a standalone Python script:

  • Use Python's urllib.request or requests library rather than subprocess.run(['curl']) to avoid environment-specific header mismatches and shell-injection risks.
  • Perform strict JSON type checking (verify the response is a list or a dict containing a tickets key).
  • Implement the "Open" filter in Python for maximum precision.

Scalable Reporting Architecture

To avoid reporting loops and delivery failures in multi-technician environments:

  • Avoid Redundant Scripts: Do not create separate .py files for different users. This leads to copy-paste errors where recipient emails and subject lines are not updated.
  • The Master-Launcher Pattern:
    • Create one Master Script that accepts parameters (e.g., tech_id, tech_email) as command-line arguments.
    • Create a small Launcher Shell Script for each user that calls the master script with the specific arguments.
    • Point the Cron jobs to these launcher scripts.
  • Recipients: Always ensure the To: header in the email construction is dynamically set to the argument passed to the script.
  • Formatting: Use inline CSS for HTML emails to ensure compatibility with Outlook, specifically using background-color on <tr> or <th> elements rather than <thead> for consistent rendering.

Scheduling Tickets (POST to /schedule/entries)

Ticket scheduling is not done via PATCH /service/tickets/{id}. Instead, create a schedule entry object via POST /schedule/entries.

  • Endpoint: POST /schedule/entries
  • Required Fields:
    • member: {"id": <memberId>} — the person being scheduled.
    • type: {"id": 4} — service ticket (type S).
    • span: {"id": 1} — span N; this is what every live entry on this instance carries and POSTs accept. (Older guidance said span id 2 B; mirror a real entry via GET /schedule/entries/{id} before deviating.)
    • status: {"id": 2} — Firm.
    • objectId: the ticket number.
    • ticketType: "S".
    • dateStart: ISO-8601 start time (e.g. "2026-09-08T15:00:00Z").
    • dateEnd: ISO-8601 end time.
    • hours: float duration (e.g. 1.0).
    • doneFlag: false.
    • meetingFlag: false.
    • name: free-form calendar label — prefix with company + ticket number.
    • where: {"id": 4} — Remote (query /service/locations for alternatives).
  • Timestamps are UTC (Z): GET responses carry Z times; convert local (PDT = UTC-7) to UTC (+7h) when POSTing (e.g. 2:30p PDT = 21:30Z). Week-window conditions accept local-offset ISO like [2026-10-05T00:00:00-07:00] and work as written.
  • Commitment pre-check: before composing slots, fetch the target week with the stable pattern in "Schedule list reliability" below and build around the member's firm entries — pre-existing all-day placeholders (hours: 0.0, start == end) don't block POSTs but should shape placement. Never plan from a cached fetch or a member-scoped conditions query.
  • Verify after POST: refetch via the unfiltered window and confirm each new entry by objectId AND intended start time — totals shift when other humans edit the calendar live. A 200 on the POST is not proof the calendar shows the entry.
  • Full runbook: references/scheduling-tickets.md carries the end-to-end validated flow incl. SLA-plan reality, priority ranking when all tickets share one priority, dispositions for externally-gated tickets, and a §8 coverage-audit (cert deadlines vs calendar, Must Schedule coverage, overlap check) — run §8 whenever the user asks to "review the calendar" or "check we're not missing anything".
  • Header: Content-Type: application/json. Send a plain JSON object, not JSON Patch.
  • Conflict Guard: If the same member already has a schedule entry at the requested time, the API returns 400 InvalidObject with SystemConflict on dateStart. Stagger entries (e.g. back-to-back hours) rather than overlapping.
  • Autoassign Entries: When tickets are created, autoassign may generate un-dated schedule entries with hours: 0.0 and no dateStart/dateEnd. These do not block POSTing a new, properly-dated entry.
  • Externally held tickets stay off the calendar but on the list: a start gated on client confirmation, vendor scheduling, or an evaluation phase gets NO schedule entry — record it in a disposition list with the reason, and book only when the gate clears. An already-booked ticket whose gate moves later keeps its entry (reschedule via PATCH, don't delete/repost).
  • Client-gated bookings carry the gate in the TICKET, not the entry label: the visible name of a ticket-linked schedule entry auto-regenerates from Company/Summary — custom text written to the entry's name field does not stick. Record scheduling gates (PENDING CLIENT CONFIRMATION, DO NOT START until X) in an internal ticket note instead; that is where the team reads constraints.
  • Booking instructions are literal id lists: book exactly the named tickets into the recommended slots; a repeated id is one booking — dedupe and note it, never invent extra bookings.

Schedule list reliability

Only two access patterns are trustworthy on this instance: (1) DIRECT GET /schedule/entries/{id} and (2) UNFILTERED date-window list (conditions=dateStart > [...] AND dateStart < [...], pageSize 500, filter member client-side). Everything else intermittently routes to the mirror: member-scoped window lists like member/id=X AND dateStart (mutating subsets, omitting entries seconds after POSTing them) and per-ticket queries like type/id=4 AND objectId=X (returning empty for entries that exist).

  • Error transparency in probe code: fetch helpers must surface the HTTP status — never convert HTTPError to an empty list/[]. A swallowed 500 reads as a legitimately empty calendar and manufactures phantom "no entries exist" conclusions.
  • Stability gate = agreement, not counts: retry a window query until two consecutive fetches return identical result sets, then trust it. Never gate on an absolute entry-count baseline — entries legitimately disappear when another human edits the calendar, so a shrinking-but-stable set is truth, not sensor noise.
  • Absence is ambiguous: a known-posted entry missing from a refetch is NOT proof of deletion or of write failure until you direct-GET the entry id (404 ⇒ deleted) AND check the ticket's status and owner — >Completed means the work was done and the block removed, an owner change means the ticket left the queue. Both remove data from view exactly like deletion does.
  • Empty results are never proof of absence — re-probe with a direct GET, and verify writes against the unfiltered window by objectId, never via conditions queries or the POST response alone.
  • Listing Existing Entries: prefer GET /schedule/entries/{id} when the entry id is known; otherwise pull the unfiltered date window and filter by objectId client-side — see "Schedule list reliability".

Condition-syntax 400s (RQL rejects the whole query, not the bad clause)

  • Bracket DateTime literals: DateStart >= [2026-10-09T07:00:00Z]; a local-offset form like [2026-10-05T00:00:00-07:00] also works. A quoted datetime 400s Expected a datetime value, not a text — quotes mark text values only.
  • No numeric in lists: chargeToId in [122, 123] 400s Expected a numeric value, not a datetime — bracketed lists parse as datetime lists. OR-chain chargeToId = 122 or chargeToId = 123 instead.
  • where is response-only on schedule/entries (where is not a recognized name) — filter by date window and read where.name/objectId client-side. objectId on a schedule entry is the related ticket id; dateStart/dateEnd are UTC Z.
  • Member identifiers are case-sensitive and fail silent: a wrong-cased member/identifier returns an empty list, not an error. Retry with exact casing (SBrown, not scbrown) before believing any member-scoped empty result.

Ticket & Entry Updates (JSON Patch)

ConnectWise PSA PATCH endpoints require a JSON Patch (RFC 6902) array, not a plain JSON object — this includes both /service/tickets/{id} AND /schedule/entries/{id}. A plain object gets 400 "The JSON input is not in the expected array format for a JSON Patch document".

  • Change Owner (validated scalar path): [{"op":"replace","path":"/owner/id","value":<memberId>}] — verify with a fresh GET; the PATCH response echoes the new value but that is not proof.
  • Move a schedule entry instead of delete+repost (preserves the entry id): [{"op":"replace","path":"/dateStart","value":"...Z"},{"op":"replace","path":"/dateEnd","value":"...Z"},{"op":"replace","path":"/hours","value":3.0}]. Do not bother patching /name on a ticket-linked entry — it accepts the write but rediscplays as Company/Summary regardless.
  • Change Status: [{"op":"replace","path":"/status","value":{"id":<statusId>}}]
  • Close/Cancel Ticket: Combine status change and flag: [{"op":"replace","path":"/status","value":{"id":80}},{"op":"replace","path":"/closedFlag","value":true}]
  • Header: Use Content-Type: application/json. The body is standard JSON; the API interprets the array format as JSON Patch.

Internal Ticket Notes (scheduling gates, handoffs)

POST /service/tickets/{id}/notes with Content-Type: application/json and a plain JSON object: {"text": "...", "internalAnalysisFlag": true, "processNotifications": false}.

  • internalAnalysisFlag: true keeps it internal (team-only). customerFlag is NOT a member of ServiceNote on this version — including it 400s with Could not find member 'customerFlag'.
  • Use these for constraints that must survive calendar edits: client-confirmation gates, coordination state, why a window moved.

Time Entry Lookups

Query time entries to see who last worked on a ticket.

  • Endpoint: GET /time/entries
  • Filter by Ticket: conditions=chargeToType='ServiceTicket' AND chargeToId=<ticketId>
  • Get Latest Entry: sort=timeEnd:desc with pageSize=1
  • Key Fields: member (technician), timeStart/timeEnd, actualHours, notes, billableOption
  • Server-side conditions are reliable on time/entries (the filtering-unreliability/mirror pitfall applies to /service/tickets, not here).

Schedule-vs-time audit ("do today's tickets have TEs?")

  1. Pull the member's schedule via an unfiltered UTC date-window list (bracket the datetimes; a local day is ~T07:00Z→T07:00Z next day for PDT) and filter by member client-side; take ticket ids from each entry's objectId.
  2. Query time/entries per ticket with the chargeTo filter above — OR-chain the ticket ids for batches (no numeric in); zero-hour NoCharge Admin TEs count as covered.
  3. A TE entered on a different date than the audited window still counts as coverage — surface the entry date when it differs. Report the gaps as an explicit ticket list (gap tickets carry the scheduled window so the user sees what to backfill).

n8n Integration & MCP Pitfalls

When offloading ConnectWise reporting to n8n via the MCP server:

  • Data Retrieval: The get_execution tool returns only metadata by default. You MUST set includeData=true to retrieve the actual node output.
  • Avoid Data Bloat: Avoid fetching large arrays (like all ticket notes) in a a single workflow. This can cause the MCP response to exceed the context window and truncate, making the data unparseable. Design "lean" workflows that return only essential IDs and summaries.
  • n8n Data Parsing: The output of an n8n execution via MCP is a double-escaped JSON string. To parse it reliably:
    1. Load the result string as a JSON object.
    2. Navigate to data -> resultData -> runData -> [Node Name] (which is a list).
    3. Access the first element of that list, then its data -> main list.
    4. Each item in the main list is often a list containing a single dictionary under the json key.
  • Workflow Publishing: Workflows using a Webhook trigger must include a Respond to Webhook node as the final step; otherwise, the workflow cannot be "published" (active) and will return an error when executed via MCP.
  • Bypassing the Mirror: If raw API conditions filters return empty results despite known data, attempt a broad fetch (omit conditions, use large pageSize) and filter the results client-side in Python.

Pitfalls & Lessons

  • Masking: Any secret passed as a string in a prompt will be truncated and cause Unauthorized errors. Always use file-based reads.
  • The Mirror: sort=dateEntered:desc does NOT fix the legacy mirror; only a conditions filter on the ID range works. Note: this condition can accidentally filter out newer tickets if the range is too restrictive (e.g. id>=110000 may miss some tickets); verify if results are unexpectedly empty.
  • Filtering Unreliability: Server-side filtering via conditions on the /service/tickets endpoint can be unreliable, returning empty results even when records exist. If filters fail, perform a broad fetch (e.g., fetch by board only) and apply specific filters (owner, status, etc.) client-side in Python.
  • Owner Name vs Identifier: When filtering by owner in n8n or raw API calls, be aware that owner/name typically contains the full name (e.g., "Scott Brown"), while owner/identifier contains the username (e.g., "SBrown"). Using a username in a filter targeting owner/name (like owner/name like '*SBrown*') will often return zero results even if the ticket is assigned to that user. Always verify which field is being targeted or filter client-side using the identifier.
  • Internal Server Errors (500): If the API returns \"Length cannot be less than zero. Parameter name: length\", this is a server-side .NET substring exception. It is typically data-dependent (caused by a malformed record in the database) and cannot be bypassed by changing query parameters or page sizes. Report this to ConnectWise support as a backend bug.
  • Board Filtering: When filtering by board in API requests, use the path-style reference board/id=X rather than boardId=X. Be aware that the API may still return tickets from other boards despite this filter; always verify the board.id field in the returned objects to ensure strictly correct results.
  • Identifying Truly Open Tickets: The closedFlag=false API filter is often imprecise and may return tickets with a status of "Closed". To identify truly open tickets, manually exclude any ticket where the status name starts with > (e.g., >Resolved), the status name is exactly "Closed", or the dateResolved field is present.
  • Board Filtering: Use the format board/id=1 in the query string to filter by board. Using boardId=1 may be ignored by the API.
  • Response Formats: The API may return a raw list [...] or a wrapped object {"tickets": [...]}. Always implement a check for both formats.
  • Header Sensitivity: The API is extremely sensitive to the Authorization header format. If encountering 401 Unauthorized with Incorrect Authorization header format despite correct keys, this is often a sign of credential corruption in the secret file rather than a tool-chain issue. Verify credentials via a raw curl call with hardcoded keys before troubleshooting the code.
  • Tooling Dependencies: When using Bash-based reporting scripts, ensure jq is installed for JSON parsing. If jq is unavailable and cannot be installed via sudo, fallback to Python's json library.
  • Execution Stability: LLM-driven cron jobs are subject to "session amnesia" and prompt interpretation variability. For mission-critical reports, use a standalone script (.py or .sh) and set the cron job to no_agent=true to run the script directly.