Files
zeus-skills/ops/connectwise/connectwise-msp/SKILL.md
T

34 KiB
Raw Blame History

name, description
name description
connectwise-msp Management of ConnectWise PSA API for VistaNet

ConnectWise PSA API Management

Comprehensive guide for interacting with the ConnectWise PSA API, specifically for VistaNet's environment.

Authentication

  • Authentication Format: Basic base64(companyidpublickey:privatekey). Based on successful session testing, the + symbol in the reference is part of the public key itself. The correct concatenation for the auth string is companyidpublickey:privatekey (e.g., VNI+rtDj...:privatekey). Do NOT add an extra + between the company ID and public key.
  • Requirement: A valid clientId header must be passed alongside the authorization. The API also requires Accept: application/vnd.connectwise.com+json; version=2022.1 or Accept: application/json for proper response handling.
  • Connection Helper: Use the provided wrapper script in scripts/cw_api.py as the PRIMARY method for all ConnectWise API calls. It handles authentication, CID injection, JSON sanitization, and correct header construction automatically. Raw curl commands are error-prone due to header formatting and base64 encoding edge cases (e.g., newline handling in base64 output).
  • Interpreter: Run cw_api.py with /usr/bin/python3 — the system python carries requests; the Hermes tooling python and ad-hoc project venvs on this host do not, so the import fails there. Importing it as a module from other working directories also needs it on the path — PYTHONPATH=/home/tech/.hermes/scripts (a module copy lives there) or cd into this skill dir; a bare from cw_api import cw_request from a scratch cwd raises ModuleNotFoundError.
  • Base URL Pattern: For the VistaNet instance, the API path typically includes the release version (e.g., https://cw.vistanetinc.com/v4_6_release/apis/3.0). Verify the exact version if requests return 404.
  • Endpoints Reference: Local copy of OpenAPI spec at references/cwendpoints.json for authoritative endpoint discovery.
  • Developer Guide: HTML reference guide at references/CWDeveloperNetworkGuide.htm for detailed API logic and patterns.
  • Note: The + symbol must be literal in the string before base64 encoding.

Troubleshooting & Pitfalls

  • Email Delivery Failures: If himalaya message send fails with a panic (index out of bounds), this is a known bug in the tool's mail parser. Fall back to providing the report as a Markdown table directly in the chat interface to ensure the user has the data.
  • API Permission Errors (403 Forbidden): Even with valid clientId and Basic Auth, certain endpoints like /company/tickets may return 403 Forbidden. This is typically a role-based permission issue within ConnectWise PSA. If this occurs, notify the user that the API member lacks the necessary permissions to fetch company-wide ticket lists.
  • system/ surface for the VNI keypair (updated live 2026-10-04 after admin granted Members Inquire)*: system/members (list + by-id) → ✅ 200; system/members/{id}/skills → ✅ 200 — the working endpoint for member skills listings (loop over active members, sleep ~0.2s). Still 403 "Security": system/skills (catalog), system/myAccount, legacy apis/2.0/Members, Reports API /Data execution (GET /system/reports name-listing still 200; MemberSkills/Data now 404 — report retired after catalog cleanup that consolidated versioned entries like 'Windows Server 2016+'). Catalog listing unavailable — read skill names from expanded skill objects in per-member responses instead.
  • Distinguishing auth-format vs permission failures: wrong auth string (e.g. literal + added when pubkey already starts with +) yields 401 "Incorrect Authorization header format"; missing security role yields 403 "Security" with a 200 on control endpoints. If control endpoint returns 200 and target returns 403, it is permissions — do not keep trying auth permutations.
  • n8n Native ConnectWise PSA Node on Agreement Endpoints: The native @mspcopilot/n8n-nodes-connectwise.connectWisePsa node may return 403 Forbidden when calling agreement endpoints (/agreement/agreements) even though the same credentials work via raw HTTP Request and /finance/agreements. When building n8n workflows for agreement/reconciliation tasks, prefer raw HTTP Request nodes using the Connectwise API credential and the /finance/... path family.

Security Bulletins & Version Triage

  • Product-line gate: ScreenConnect, Automate, and PSA have separate CVE streams. Verify which products the stack actually runs before assigning exposure from a ConnectWise headline — this stack runs no ScreenConnect (BeyondTrust/Bomgar covers remote sessions) and no Automate, so those bulletins are read-and-ignore.
  • On-prem means no auto-remediation: bulletin guidance is written as "cloud instances update automatically; on-premise apply the release yourself" — on-prem PSA inherits every affected-versions clause ("all versions prior to X") with no ConnectWise safety net.
  • After any bulletin/upgrade: raise PSA Desktop clients to the matching release build (server and client render the same content and are both listed as affected modules) and make users sign out and back in — pre-upgrade sessions keep the vulnerability state until re-auth.
  • On-prem-only attack surface exists: some advisories are on-premise-only (the Windows MSMQ/TCP-1801 execution advisory was) — treat PSA server listening ports as permanent in-scope surface for Nexpose/Sophos and never DNAT them to the WAN.
  • Functional release notes are SSO-gated: docs.connectwise.com release notes (e.g. ConnectWise_PSA_(Manage)_Release_Notes/..._2026_Release_Notes) have no public mirror; every anonymous path returns an SSO login page. Read them with a vaulted ConnectWise login via the browser tool, or in the ConnectWise Home release-notes panel during the upgrade.

Finance Endpoints & Patterns

  • Agreement Status: Use the agreementStatus property to determine if an agreement is Active, Expired, etc.
  • Filtering Agreements: Server-side conditions for companyId (e.g., company/id={id}) can be unreliable or omit certain agreement types. If agreements are expected but not found, fallback to searching by name using a broad partial match (e.g., /finance/agreements?conditions=name like '%ClientName%').
  • Agreement Search Path Trap: GET /agreement/agreements may return 403 Forbidden even when the API member has Finance/Agreement Product read rights. On this instance the working search path is GET /finance/agreements with conditions=name='...' or name='...'. The header and credential requirements are identical; only the path differs. If one path 403s, try the other.
  • Agreement company filter IS reliable here: unlike /service/tickets and /company/configurations (whose companyId params are silently ignored), GET /finance/agreements?conditions=company/id={id} returns exactly that company's agreements — but note the fields param interacts with sub-objects: requesting fields=id,summary,...,company strips nested objects on some endpoints, so drop fields when nested data is needed.
  • Agreement Additions: Use GET /finance/agreements/{id}/additions to retrieve line-item details for usage-based billing. Note: The product name/identifier is stored in product.identifier rather than product.name.
  • Usage-Based Billing: Agreements with a billAmount of 0.0 may still be active and billed based on actual usage/license counts. Check recent invoices if the agreement amount is zero but the service is known to be active.
  • Backup Billing Reconciliation: When comparing VSPC actual usage to ConnectWise billed usage, the canonical flow is:
    1. GET /finance/agreements?conditions=name contains 'Veeam' plus the Veem typo variant, then cross-check generic-named Backup-type agreements whose additions carry VEEAM product identifiers (name filters alone miss several active agreements).
    2. For each agreement: GET /finance/agreements/{id}/additions.
    3. Filter additions for live lines: billCustomer='Billable', extPrice>0, cancelledDate empty or future; sum per COMPANY (not per agreement) since On-Site + Off-Site agreements share consumption.
    4. Pull VSPC actual usage live via the /uiapi endpoints (see the vspc-billing-audit skill) — the legacy CSV snapshots in Nextcloud go stale.
    5. See references/backup-billing-reconciliation.md for a complete n8n workflow pattern.

n8n Workflow Patterns for ConnectWise

Agreement Additions with Raw HTTP Request

When the native ConnectWise PSA node is blocked or behaves differently from raw API calls, use HTTP Request nodes with the Connectwise API (httpBasicAuth) credential:

  1. Add clientId and companyId headers explicitly.
  2. Use GET https://cw.vistanetinc.com/v4_6_release/apis/3.0/finance/agreements with query parameter conditions=name='Veeam Off-Site Backup' or name='MSP On-Site Backup'.
  3. Follow with GET /finance/agreements/{{ $json.id }}/additions.
  4. On both nodes enable Continue on Fail and Always Output Data (options.continueOnFail: true, options.alwaysOutputData: true) so empty or failed additions do not kill the execution chain.
  5. Add a Code node to merge additions back onto the parent agreement object, because the HTTP Request node replaces the input item with its response:
    const results = [];
    for (let i = 0; i < $input.all().length; i++) {
      const additionItem = $input.all()[i];
      const agreementItem = $("Fetch CW Backup Agreements").all()[i];
      const agreement = agreementItem ? agreementItem.json : {};
      let additions = [];
      if (Array.isArray(additionItem.json)) additions = additionItem.json;
      else if (additionItem.json && typeof additionItem.json === "object") additions = [additionItem.json];
      results.push({ json: { ...agreement, additions } });
    }
    return results;
    
  6. Credential assignment: HTTP Request nodes do not auto-assign in n8n SDK-created workflows. Open the workflow canvas and manually select the Connectwise API credential on each HTTP Request node.

Company & Configuration Lookup Patterns

Finding a Company by Name

Exact name matching (e.g., name contains "ABCO & Sons") often returns empty because the ConnectWise name field may use ampersands, commas, or abbreviations.

  • Reliable Pattern: Search with a broad partial match first (e.g., name contains "ABCO"), then inspect results to identify the correct company by its identifier.
  • Once identified, cache the company.id for all subsequent queries.

Configuration & Agreement Retrieval

To find infrastructure or service details for a client:

  • Configurations: /company/configurations?companyId={id} — use this to list devices, ISP configs, switches, printers, workstations, servers.
  • Agreements: /finance/agreements?conditions=company/id={id} — use this for contracts, bandwidth plans, backup agreements, etc.
  • Agreement Types: Filter by type.name (e.g., ISP Bandwidth/Fiber, MSP On-Site Backup) to narrow results.
  • Key Fields:
    • Configs: name, type.name, status.name, ipAddress, modelNumber, vendorNotes
    • Agreements: name, agreementStatus, type.name, billAmount, startDate, nextInvoiceDate

Critical Pitfalls & Workarounds

1. Unreliable Conditional Filtering

The conditions parameter (e.g., owner/identifier="SBrown") is inconsistent and often returns empty lists or ignores negative operators like !=.

  • Reliable Approach: Fetch results using pageSize and filter JSON locally in Python. Note: The companyId parameter on /service/tickets is silently ignored by the API and returns tickets from all companies. Always filter client-side by company.id after fetching. See references/ticket_fetching.md for the working pattern.
  • Filtering Pitfall: Server-side conditions for identifiers (e.g., owner/identifier='SBrown') require the value to be enclosed in single or double quotes. Failing to do so results in an ApiFindCondition syntax error.
  • DateTime conditions require square brackets: date/time literals in conditions (e.g., /schedule/entries date filters) must be enclosed in [...] — bare ISO strings 400 with Unrecognized value. Working: dateStart >= [2026-10-02T00:00:00Z].
  • Configurations Endpoint Mirroring: The companyId query param on /company/configurations is also silently ignored, returning configs from all companies — BUT the conditions string DOES work here (verified 2026-10-20: conditions=company/id=<companyId> returned only that company's configs). Prefer the conditions form; fall back to client-side filtering by company.id after fetching. This mirrors the ticket endpoint param behavior, not the conditions behavior.
  • Scaling Pitfall: Local filtering of large datasets often causes timeouts.
  • Defining "Open" Tickets: For this user, an "open" ticket is defined by meeting ALL of these criteria: closedFlag=false, status name does NOT start with > (e.g., >Closed, >Completed), and dateResolved is NULL/absent.
  • CRITICAL API MIRRORING BUG: The general list endpoint (/service/tickets) and closedFlag=false filter frequently route to a legacy mirror/cache (returning tickets from ~2011) and omit current production tickets.
    • Production Data Bypass (Tickets): The legacy mirror bug is resolved for tickets. However, it PERSISTS for the /schedule/entries endpoint.
    • Calendar Workaround: Member-filtered schedule queries (member/id={id} AND dateStart >= [UTC] AND dateStart <= [UTC]) have returned current production data — but ALWAYS sanity-check returned dateStart values. If entries show 2011-era dates, the mirror bug is active for /schedule/entries; fall back to requesting a manual CSV/PDF calendar export from the user.
  • Contact Email Extraction: To retrieve a contact's email address, do NOT look for an emailAddress field on the Contact object. Instead, fetch the /company/contacts/{contactId}/communications endpoint and locate the entry where type.name == "Email" or communicationType == "Email"; the address is stored in the value field.
  • Filtering Logic: Once production data is retrieved, combine the following to reliably identify active work:
    • Client-side (Logic): Filter out tickets where the status name starts with > (e.g., >Closed, >Completed) AND tickets that have a dateResolved value.
  • Reporting Convention (Open Tickets):
    • Board: VistaNet Help Desk
    • Retrieval: Use conditions=id >= [RecentID] to avoid legacy mirroring.
    • Logic: (Status NOT starting with > AND dateResolved is NULL)
    • Ordering: Oldest to Newest (dateEntered)
    • Fields: Ticket #, Company, Status, Summary
  • Importance ranking (user-accepted): when asked to list tickets by importance, sort by priority sort asc, then isInSla=false first, then ticket id asc.
  • Reply shape: lead with a status count table computed from the CURRENT fetched list (Counter, never a prior report's numbers), flag SLA breaches inline on each row, and close with a short changes-since-last-report note when a prior report ran in the same session.
  • All times shown to the user are Pacific: API timestamps are Z-suffixed UTC — convert (UTC-7 PDT / UTC-8 PST) before presenting. API writes (schedule entries, time entries) stay UTC.
  • Disappeared ticket: when a previously-listed ticket drops out, read its detail before reporting it gone — closedFlag can stay false while status goes >-prefixed with dateResolved set (resolved, not vanished).
  • Owner Identification Pitfall: The member object is frequently missing from the top-level ticket JSON in certain versions of the API.
  • Reliable Identification: The ticket owner(s) are stored in the resources field as a comma-separated string of member identifiers (e.g., "resources": "BRiffel, SBrown").
  • Filtering Logic: To identify tickets owned by a specific technician, split the resources string by commas, strip whitespace, and check for an exact match of the identifier.

2. Authentication Format

The API uses Basic Auth with a specific concatenated string.

  • Format: Authorization: Basic base64(companyidpublickey:privatekey)
  • Crucial: The + symbol is part of the public key itself. The correct concatenation is companyidpublickey:privatekey. If the public key starts with a +, it is included as part of the key string. Do NOT add an extra + between the company ID and the public key.
  • Requirement: A valid clientId header must be passed alongside the authorization. If the clientId is incorrect or disabled, the API returns a 401 Unauthorized even if the Basic Auth string is correct.
  • Credential Storage: Credentials and technical metadata are stored in /home/tech/connectwise_reference.md and ~/.secrets/connectwise. To avoid Hermes runtime credential masking (which truncates keys to prefix...suffix), always read credentials from disk at runtime rather than embedding them in prompts or script strings.

3. Updating Tickets (JSON Patch)

ConnectWise PSA uses the JSON Patch (RFC 6902) format for PATCH requests.

  • Format: The payload must be a JSON array of operation objects.
  • Working Example (Updating Owner):
    [
      {
        "op": "replace",
        "path": "/owner",
        "value": {"identifier": "<memberIdentifier>"}
      }
    ]
    
  • Pitfall: Sending a standard JSON object (e.g., {"owner": {"identifier": "<memberIdentifier>"}}) will result in a 400 Bad Request stating that the input is not in the expected array format.
  • Pitfall (field-specific): The sub-property rule is not universal. For /owner, replacing the whole object (/owner → {"identifier": "<memberId>"}) or the sub-path ("path": "owner/identifier", "value": "<memberIdentifier>") both work when the payload is a native Python list. For ticket status, the sub-property path IS the verified working pattern: [{"op":"replace","path":"status/id","value":<statusId>}] — resolve the numeric ID first via GET /service/boards/{boardId}/statuses (VistaNet Help Desk board = 1; "In Progress" = id 1, "On Hold" = 10, "Waiting Client Response" = 439, "Scheduled" = 13; status names starting with > are closed-type). Match the user's requested status name case-insensitively against the actual board status names before patching — the board may not carry their exact wording (no "Waiting On Client" status exists; the semantic equivalent is "Waiting Client Response"): apply the equivalent and say so in the reply.
  • Pitfall (payload type): json_data must be a native list/dict, never a pre-encoded JSON string — a string arrives quoted and the API 400s with "not in the expected array format for a JSON Patch document" even when the string itself is a valid array (observed on owner PATCH 2026-09-29; the skill doc's stringified example is illustrative, not literal).
  • Owner assignment workflow: GET the ticket first to capture before-state (owner/resources/status), PATCH [{"op":"replace","path":"/owner","value":{"identifier":"<id>"}}], then re-GET and confirm the changed field. The PATCH response already echoes the full updated owner object (numeric id + identifier + name), but the read-back is the verification. Side effect: the API auto-adds the new owner to the comma-separated resources string and leaves the former owner listed there — expect it and report it as part of the change; do not treat it as a manual resources edit.
  • Verification: After patching, read the ticket back (e.g., GET /service/tickets/{id}?fields=id,status) and confirm the changed field.

4. Scheduling Logic

Creating schedule entries requires specific fields that differ from standard ticket updates:

  • Date Fields: Use dateStart and dateEnd (ISO 8601 UTC).
  • Object Linking: Use objectId and ticketType: "S" (uppercase, for Service Tickets) instead of chargeTo.
  • Required Type: The type field is required. Use "type": {"id": 4} (Service Ticket schedule type).
  • Span: Use "span": {"id": 2} (identifier "B") — accepted on this instance; "span": {"id": 1} (identifier "N") also appears on production ticket entries. Either is accepted.
  • Pitfall: The ScheduleEntry object does NOT support a description field. Including one in the request payload will cause a 400 Bad Request stating the member could not be found on the object.

Ticket-less Calendar Meetings (no ticket to attach)

objectId is REQUIRED on POST /schedule/entries (400 MissingRequiredField without it), and the API resolves any nonzero objectId as a TICKET id — POSTing objectId=<some other record's id> silently links the entry to whatever ticket has that number and overwrites name with that ticket's summary, no matter what name you sent. There is no "personal activity" objectId; do not try company or member ids as a workaround.

  • Working pattern: "type": {"id": 7} (identifier "M" = Meeting), "objectId": 0, "span": {"id": 2}. With objectId 0, name is free-form and honored.
  • Discovering schedule types: GET /schedule/types returns a 403 Security error on this instance, and GET /calendar/generalTypes returns empty. Find real types by sampling production entries: GET /schedule/entries?conditions=dateStart >= [recent UTC] and tally the type objects (observed: 4='S' Service, 7='M' Meeting, 1='C', 6='V', 2='X'), then model the payload on a real entry of that type.
  • Defaults matching the user's existing entries: status {"id": 2} (Firm), where {"id": 4} (Remote), reminder {"id": 4} (15 min), member {"id": <memberId>}.
  • Wrong-entry cleanup: DELETE /schedule/entries/{id} works and returns an empty body; verify with GET (expect NotFound) before recreating. Never leave a mislinked entry on the user's calendar.
  • Auto-created placeholder entries: ticket creation auto-generates ScheduleEntry rows per resource with dateStart/dateEnd = null (observed 3 on creation, id-adjacent). These are resource-assignment artifacts, not calendar blocks — when auditing a ticket's schedule, filter/skip null-date entries before counting real scheduled work.
  • Always verify: re-GET the member's day window after creating any schedule entry and confirm id/times/type/name. POST returns 200 even when the entry landed on the wrong object — a successful response is not proof of a correct entry.

5. Permission Gaps

Certain endpoints (like /changelog or /service/boards/{id}/tickets) may return 403 Forbidden even if the API member can read individual tickets. This is a role-based permission issue in ConnectWise.

  • Member Lookup Gap: The /system/members endpoint also requires elevated security permissions. If a 403 Forbidden is returned there, fall back to resolving member identifiers from ticket-level resources fields or from the owner object returned on individual ticket reads (which does not require the /system/members permission).
  • Member ids: never guess a numeric id — resolve unknown members from member.id embedded on their notes, time entries, or ticket owner objects (or GET system/members); verify against a live object before use in a PATCH.
  • Member skills live state (2026-10-04, recommendations applied by user in CW): skill coverage is 7/12 humans — the covered members' per-member skill lists span Microsoft 365, Veeam B&R, Sophos Firewalls, n8n/Workflow Automation, Certificate Management, AI Integration/Engineering, RingCentral, VMware, Exchange 2016+, Quotewerks, Ingram Micro Procurement, KnowBe4 and Desktop/Printers (member names and per-member counts redacted from the hub copy; two profiles still skillless: one needing billing/agreement skills, one subcontractor profile). Skill-based triage routing can target the 7 covered members; 13 API/integration members correctly carry no skills.

6. Ticket Activity & Last Author Inspection

To determine who last touched a ticket — useful for triaging stale tickets or verifying whether the owner was the last person to update it:

Endpoint Chain (try in order)

  1. Ticket Notes (primary): GET /service/tickets/{id}/notes?sort=dateCreated desc&pageSize=1&fields=createdBy,enteredBy,dateCreated
  2. Documents (fallback): GET /service/tickets/{id}/documents?sort=createdDate desc&pageSize=1&fields=createdBy,enteredBy,createdDate
  3. Time Entries (final fallback): GET /time/entries?conditions=chargeToId={id}&sort=timeEnd desc&pageSize=1&fields=enteredBy,member,timeEnd

Author Normalization Pitfall

The author field name varies by endpoint (createdBy, enteredBy, member). The value may be a string or a member dict. Always normalize:

author = note.get('createdBy') or note.get('enteredBy')
if isinstance(author, dict):
    author = author.get('identifier') or author.get('name')
author = str(author).strip()

Notes Field Gotchas

  • Notes use dateCreated, not dateEntered — filtering notes on dateEntered silently matches nothing. orderBy on the notes endpoint is rejected (400 InvalidOrderBy); sort client-side. Time entries DO use dateEntered.
  • Notes GET returns oldest-first: GET /service/tickets/{id}/notes lists from the oldest note regardless of pageSize. To verify a POST landed, fetch with a pageSize at/above the note count and match the returned note id (the new note is the last element) — a small pageSize spot-check shows only the oldest notes and makes a successful POST look lost.
  • Contact-authored notes (email-to-ticket) have no member object — only contact. Exclude them, along with the owner's own notes, when counting other-technician activity.
  • Automated integration notes (e.g. VNI Bot) are members but not technicians — report them separately from human-tech activity.
  • A ticket with recent activity can return 0 notes and 0 time entries; check dateResponded/slaStatus on the ticket detail (which carries no lastUpdate object on this instance).

Typical Workflow

  • Fetch open tickets for a given owner (e.g. SBrown) using the robust patterns above.
  • For each ticket, run the endpoint chain and capture the most recent author.
  • Compare the last author against the ticket owner. If they differ, the ticket has been touched by someone else since the owner last updated it.
  • This produces a clean "tickets needing your attention" list.

7. Alert-Storm Triage (validated live 2026-10-06)

  • Dedupe rule that works: group open P1s by (company, lowercase unescaped summary); keeper = highest id (newest); each closed dup gets an internal pointer note, then status → the alerts board's >-closed status (board 6 id=109). 42 closes in one run, 0 failures at ~0.3s pacing.
  • Ticket POST bodies that worked: priority: {"id": 6} = "Priority 1 - Critical"; status: {"id": 107} = board 6 "reviewed". /service/priority (singular) is a 404; get valid priority ids from any live ticket's priority object. status: {name} in a POST body fails.
  • Ticket CREATE pitfalls (validated live 2026-10-07, VistaNet Help Desk board): summary hard cap = 100 chars (400 MaxLengthField, exact error text returned). status: {"name": "New"} DID work on the Help Desk board create (the board-6 failure note above is board-specific). initialDescription does NOT read back as the ticket's description (GET shows empty) — it silently becomes INTERNAL note #1 (detailDescriptionFlag). Priority id 4 does NOT exist on this instance; pull valid priority ids from any live ticket (priority id 8 = Priority 3 - Medium, id 6 = Priority 1 - Critical). Contact on create: "contact": {"id": N} accepted fine.
  • Ticket description length (user preference, 2026-10-07): keep the initial description to a FEW SENTENCES maximum — symptom, root cause, resolution in brief. Full diagnostics detail goes in as a separate internal note, not in the create payload.
  • Contact lookup: GET /company/contacts?conditions=company/id={id} AND lastName='...' works; defaultEmail is NOT a queryable condition field (400 ApiFindCondition) — filter by company + name instead.
  • Child linking: PATCH /service/tickets/{id} with [{"op":"add","path":"/parentTicketId","value":<parent>}] returns 200, but parentTicketId has NO readback (never in GET/field projections, hasChildTicket stays False) and /service/tickets/{id}/children(Conditions) endpoints 404 on v4_6_release. Verification via API is impossible — always also post an internal "Child of parent #X" note on each child; that note is the durable, human-visible wiring.
  • Link-slip hazard: when deduping, map keeper vs dup carefully — linking the closed dup id instead of its keeper is the natural mistake (happened in first pass); log every PATCH target and reconcile against the dedupe plan afterward.
  • Runbook: 1) fetch open P1s via closedFlag=false + priority sort==1, 2) build dedupe plan (protect genuinely distinct alerts), 3) note-then-close dups, 4) create one parent per per-client cascading failure family with a child-list note, 5) PATCH parentTicketId per child + child notes, 6) verify: keepers still open, dups closed, fresh-P1 count = original − closed. Scratch scripts: cw_triage_plan.json / cw_close_dupes.py / cw_create_parents2.py / cw_verify_final2.py pattern. The API occasionally returns responses containing non-printable control characters that cause standard JSON parsers (like Python's json.loads) to fail with Invalid control character.
  • Reliable Fix: Clean the response text using regex before parsing: re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f]', '', response_text).
  • Scaling Workaround: When fetching large lists, avoid pageSize=1000. Large blocks are more likely to contain a corrupting character. Fetch in small batches (e.g., 100-200 records) using ID ranges (e.g., conditions=id>=110000 AND id<110200). This isolates corruption to a single small batch rather than failing the entire request.

Ticket Merge

  • True merge unavailable: POST /service/tickets/{parentId}/merge exists (TicketMerge schema requires mergeTicketIds array + status object) but returns 403 Security for the VNI API member — do not retry.
  • Working fallback: internal merge-pointer note on the ticket being merged away → PATCH status to the board's >-closed status (>Closed = board 1 id 17; dateResolved and closedFlag set automatically) → internal note on the parent recording the merge. Verify with a GET (status/closedFlag/dateResolved) and notes read-back on both tickets.
  • The dedup rule: automated alarm tickets on other boards (e.g. Managed Backups, board 30, no tech owner) are NOT merge candidates for a Help Desk ticket unless the user names them.

8. Time Entries, Ticket Notes & Documentation Audits

Time entries and notes are SEPARATE objects, and for this user a time entry does NOT count as a note: when the ask is "document this" or "do I have notes on X", only ticket notes satisfy it.

  • Create time entry: POST /time/entries — payload needs company{id}, member{id}, chargeToId (ticket #), chargeToType: "ServiceTicket", locationId, businessUnitId, workType{id}, workRole{id}, timeStart/timeEnd (ISO 8601 UTC), actualHours, billableOption ("Billable"/"NoCharge"), notes. VistaNet working defaults: locationId 11, businessUnitId 1, workType 3 (Regular), workRole 4 (Systems Engineer). Convert local PDT times to UTC by adding 7 hours.
  • Pitfall: addToDetailDescriptionFlag: true on a time entry does NOT create a ticket note on this instance. If the text must appear on the ticket, POST the note separately.
  • Create ticket note: POST /service/tickets/{id}/notes with text + internalFlag/detailDescriptionFlag/internalAnalysisFlag/resolutionFlag (internal IR/triage notes: internalFlag: true + internalAnalysisFlag: true). Do NOT send customerVisibleFlag — ServiceNote has no such member and the API rejects the whole request with "Could not find member 'customerVisibleFlag' on object of type 'ServiceNote'".
  • cw_request payload pitfall: cw_request(method, endpoint, params=None, json_data=None) — passing the payload as the third positional argument sends it as URL query params, not a JSON body, which returns a silent 400 on note POSTs. Always pass write payloads with the json_data= keyword.
  • Attribution: API-created notes are stamped with the API member (TinMan), not an arbitrary author — tell the user when posting a note on their behalf.
  • Response shapes: cw_api.cw_request returns (data, status_code) tuples and list endpoints can nest the payload one level deeper — normalize before any .get() or attribute loops.
  • Documentation audit: to answer "was my scheduled work documented", GET /schedule/entries filtered by member/id={id} AND dateStart >= [UTC] AND dateStart <= [UTC], then per ticket GET notes AND timeEntries; count only notes attributed to the member (identifier match). Report per ticket — notes present / time-entry-only / nothing — BEFORE proposing fixes.
  • Exact working payloads and the normalization helper: references/ticket-time-notes.md.
  • Full activity requires BOTH endpoints: a ticket can have few notes but rich time entries (e.g. multi-day builds logged entirely as one tech's time entries). When asked for "the details/notes" on a ticket, pull /notes AND /time/entries?conditions=chargeToId={id} before concluding history is thin — and report time-entry work separately from notes.
  • Plaintext credentials on tickets: time-entry notes and customer-visible notes sometimes contain passwords/license keys. When summarizing or displaying a ticket, flag any credential material found and note which notes are customer-visible vs internal — do not silently repeat secrets in chat output; show them once with a hygiene warning.
  • references/ticket-time-notes.md — Verified POST payloads for time entries and ticket notes, status PATCH, schedule-day documentation audit pattern.
  • references/ticket_fetching.md — Robust ticket retrieval patterns (avoids legacy mirror bug, ID-range paging, client-side filtering) plus the full-corpus note-text search recipe (childconditions on notes/text is silently ignored by /service/tickets/search — scan per-ticket notes instead).
  • references/isp-bandwidth-lookup.md — Agreement and configuration lookup for ISP bandwidth/client infrastructure.
  • references/meeting-agenda-ticket-correlation.md — How to search ConnectWise for existing tickets when a user mentions client-specific agenda items (locations, projects, systems).
  • references/backup-billing-reconciliation.md — Complete n8n workflow pattern for comparing CW backup agreement additions to VSPC usage via Postgres registry and Nextcloud usage CSV.