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.mdfor 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 Authheader:base64([CompanyID][PublicKey]:[PrivateKey]).- Secret file format:
~/.secrets/connectwiseis aKEY=valuefile 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.mdfor the proven build. - The
+Logic: the+is part of the PublicKey value on this instance. Build the pre-encoded string ascompanyid + publickey + ":" + privatekeyby plain concatenation (e.g.VNI+rt...:priv...) — do not insert a second+(avoidVNI++rt...).
- Secret file format:
- Member identity:
/system/myMemberreturns 404 for API members on this instance. Resolve the acting member withGET /system/members?conditions=identifier='<memberIdentifier>'(URL-encode the quotes) and reuse the returnedidin 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
conditionsparameter 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>=110000filter 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
conditionsfilter is required. - Server-Side Crashes: Be aware that the
/service/ticketsendpoint can throw a500 Internal Server Errorwith the message"Length cannot be less than zero. Parameter name: length". This is a .NET internalSubstring()exception typically caused by malformed data in a specific record. It is generally not solvable by adjusting request parameters (likepageSize) and requires ConnectWise support to resolve. - Owner Logic: For ownership filtering, check both the
owner.identifierfield and theresourcesstring (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"
dateResolvedis 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
conditionsand a largepageSize(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.requestorrequestslibrary rather thansubprocess.run(['curl'])to avoid environment-specific header mismatches and shell-injection risks. - Perform strict JSON type checking (verify the response is a
listor adictcontaining aticketskey). - 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
.pyfiles 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.
- Create one Master Script that accepts parameters (e.g.,
- 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-coloron<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 (typeS).span:{"id": 1}— spanN; this is what every live entry on this instance carries and POSTs accept. (Older guidance said span id 2B; mirror a real entry viaGET /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/locationsfor alternatives).
- Timestamps are UTC (
Z): GET responses carryZtimes; 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.mdcarries 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 InvalidObjectwithSystemConflictondateStart. 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.0and nodateStart/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'snamefield 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
HTTPErrorto 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 —
>Completedmeans 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 400sExpected a datetime value, not a text— quotes mark text values only. - No numeric
inlists:chargeToId in [122, 123]400sExpected a numeric value, not a datetime— bracketed lists parse as datetime lists. OR-chainchargeToId = 122 or chargeToId = 123instead. whereis response-only onschedule/entries(where is not a recognized name) — filter by date window and readwhere.name/objectIdclient-side.objectIdon a schedule entry is the related ticket id;dateStart/dateEndare UTCZ.- Member identifiers are case-sensitive and fail silent: a wrong-cased
member/identifierreturns an empty list, not an error. Retry with exact casing (SBrown, notscbrown) 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/nameon a ticket-linked entry — it accepts the write but rediscplays asCompany/Summaryregardless. - 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: truekeeps it internal (team-only).customerFlagis NOT a member of ServiceNote on this version — including it 400s withCould 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:descwithpageSize=1 - Key Fields:
member(technician),timeStart/timeEnd,actualHours,notes,billableOption - Server-side
conditionsare reliable ontime/entries(the filtering-unreliability/mirror pitfall applies to/service/tickets, not here).
Schedule-vs-time audit ("do today's tickets have TEs?")
- 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. - Query
time/entriesper ticket with the chargeTo filter above — OR-chain the ticket ids for batches (no numericin); zero-hour NoCharge Admin TEs count as covered. - 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_executiontool returns only metadata by default. You MUST setincludeData=trueto 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:
- Load the
resultstring as a JSON object. - Navigate to
data->resultData->runData->[Node Name](which is a list). - Access the first element of that list, then its
data->mainlist. - Each item in the
mainlist is often a list containing a single dictionary under thejsonkey.
- Load the
- Workflow Publishing: Workflows using a Webhook trigger must include a
Respond to Webhooknode 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
conditionsfilters return empty results despite known data, attempt a broad fetch (omitconditions, use largepageSize) 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
Unauthorizederrors. Always use file-based reads. - The Mirror:
sort=dateEntered:descdoes NOT fix the legacy mirror; only aconditionsfilter on the ID range works. Note: this condition can accidentally filter out newer tickets if the range is too restrictive (e.g.id>=110000may miss some tickets); verify if results are unexpectedly empty. - Filtering Unreliability: Server-side filtering via
conditionson the/service/ticketsendpoint 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/nametypically contains the full name (e.g., "Scott Brown"), whileowner/identifiercontains the username (e.g., "SBrown"). Using a username in a filter targetingowner/name(likeowner/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=Xrather thanboardId=X. Be aware that the API may still return tickets from other boards despite this filter; always verify theboard.idfield in the returned objects to ensure strictly correct results. - Identifying Truly Open Tickets: The
closedFlag=falseAPI 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 thedateResolvedfield is present. - Board Filtering: Use the format
board/id=1in the query string to filter by board. UsingboardId=1may 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
Authorizationheader format. If encountering401 UnauthorizedwithIncorrect Authorization header formatdespite correct keys, this is often a sign of credential corruption in the secret file rather than a tool-chain issue. Verify credentials via a rawcurlcall with hardcoded keys before troubleshooting the code. - Tooling Dependencies: When using Bash-based reporting scripts, ensure
jqis installed for JSON parsing. Ifjqis unavailable and cannot be installed viasudo, fallback to Python'sjsonlibrary. - Execution Stability: LLM-driven cron jobs are subject to "session amnesia" and prompt interpretation variability. For mission-critical reports, use a standalone script (
.pyor.sh) and set the cron job tono_agent=trueto run the script directly.