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

158 lines
20 KiB
Markdown

---
name: connectwise-psa-reporting
description: 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.
### Scripted Reports (Recommended for Stability)
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.