ConnectWise PSA skills and tools under ops/connectwise (hub-sanitized 2026-11-01: client intel, internal workflow/credential/member/company ids, archived-page apiToken/sessionId stripped to placeholders)
This commit is contained in:
commit
9c96092034
36 files changed
+310875
No files matched your search
@@ -0,0 +1,7 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[co]
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.log
|
||||||
|
auth.json
|
||||||
|
.secrets/
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# zeus-skills
|
||||||
|
|
||||||
|
Skill hub for the Zeus / VistaNet agent fleet.
|
||||||
|
|
||||||
|
Layout: `ops/<domain>/<skill>/`
|
||||||
|
|
||||||
|
Current contents:
|
||||||
|
- `ops/connectwise/` — ConnectWise PSA skills and tools (8 skills + bundled scripts)
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# ops/connectwise — ConnectWise PSA skills & tools
|
||||||
|
|
||||||
|
Published from VistaNet's Hermes fleet skill set (`~/.hermes/skills`).
|
||||||
|
|
||||||
|
**Credentials are NOT embedded.** Scripts resolve CW API auth from
|
||||||
|
`~/.secrets/connectwise` (a KV file) or `CW_*` environment variables.
|
||||||
|
Required keys:
|
||||||
|
|
||||||
|
```
|
||||||
|
CW_COMPANY_ID=VNI
|
||||||
|
CW_PUBLIC_KEY=...
|
||||||
|
CW_PRIVATE_KEY=...
|
||||||
|
CW_CLIENT_ID=<uuid from CW Developer Network>
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: the fleet stores the public key with a leading `+`
|
||||||
|
(`CW_PUBLIC_KEY=+...`); the loaders normalize it (`lstrip("+")` / `++` collapse)
|
||||||
|
before building the `companyid+publickey:privatekey` Basic auth string.
|
||||||
|
A `clientId` header is always required — 401 with a valid Basic auth means the
|
||||||
|
client ID is missing or disabled.
|
||||||
|
|
||||||
|
| Directory | Purpose | Scripts |
|
||||||
|
|---|---|---|
|
||||||
|
| `connectwise/` | CW API core: schedule entries, ticket create/notes, board & status IDs, env-var auth | `connectwise_api.py`, `cw_company_ticket_audit.py`, `cw_week_schedule_plan.py` |
|
||||||
|
| `connectwise-msp/` | Deep PSA reference: auth format, `cwendpoints.json` (full API spec), ticket fetching, time notes | `cw_api.py` (workhorse: `cw_request()`), reads `~/.secrets/connectwise` |
|
||||||
|
| `connectwise-reporting/` | Cross-company reporting recipes (server-side filtering traps, raw API auth) | `top-companies-by-hours.py`, `fetch-customer-time-entries.py` |
|
||||||
|
| `connectwise-n8n-automation/` | CW × n8n automation patterns | — (docs) |
|
||||||
|
| `connectwise-open-tickets-robust/` | Robust open-ticket fetching (handles empty/stale API results) | `schedule_cert_renewals.py` |
|
||||||
|
| `connectwise-open-tickets-report/` | Open-ticket report generator | `generate_open_tickets.py` |
|
||||||
|
| `connectwise-psa-reporting/` | Raw API reporting quirks + API reference | — (docs) |
|
||||||
|
| `cw-triage-workflow/` | ConnectWise triage workflow for n8n triage builds | — (docs) |
|
||||||
|
|
||||||
|
Sanitization applied on publish: hardcoded `client id`, public key, and private
|
||||||
|
key literals were replaced with `_load_cw_client_id()` / `_load_creds()` loaders
|
||||||
|
and `<PLACEHOLDER>` text in the reference docs. Run scripts with system Python
|
||||||
|
(`/usr/bin/python3` on the fleet host) — the Hermes-bundled 3.14 runtime lacks
|
||||||
|
`requests`.
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
---
|
||||||
|
name: connectwise-msp
|
||||||
|
description: 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:
|
||||||
|
```js
|
||||||
|
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)**:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"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:
|
||||||
|
```python
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Related References
|
||||||
|
- `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.
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,24 @@
|
|||||||
|
# ISP / Bandwidth Agreement Lookup Pattern
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Find a client's Internet connectivity details (speed, ISP, circuit ID, cost) using only the ConnectWise PSA API.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
1. **Resolve the company** using a broad partial name search:
|
||||||
|
`GET /company/companies?conditions=name contains "{partial}"`
|
||||||
|
Inspect results and match by `identifier` (e.g., `ABCOInsurance`).
|
||||||
|
2. **List ISP configurations** (if any):
|
||||||
|
`GET /company/configurations?companyId={id}`
|
||||||
|
Filter by `type.name == "ISP"` or inspect `name` for ISP keywords.
|
||||||
|
3. **Fetch agreement details**:
|
||||||
|
`GET /finance/agreements?conditions=company/id={id}`
|
||||||
|
Filter by `type.name` (e.g., `ISP Bandwidth/Fiber`) to locate the bandwidth contract.
|
||||||
|
4. **Key fields to surface**:
|
||||||
|
- Agreement: `name`, `agreementStatus`, `billAmount`, `startDate`, `nextInvoiceDate`
|
||||||
|
- Config: `name`, `ipAddress`, `modelNumber`, `vendorNotes`
|
||||||
|
- Notes may contain circuit IDs, port details, or rack locations.
|
||||||
|
|
||||||
|
## Example Session (worked example; client details redacted)
|
||||||
|
- Company ID: `<companyId>`, Identifier: `<ClientIdentifier>`
|
||||||
|
- Config: `<site>-Comcast Fiber` (ISP type) with circuit notes
|
||||||
|
- Agreements: `<tier> Internet Fiber` (`$<amount>/mo`) + `municipal/university easement riders` (`$<amount>/mo)`
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Correlating Meeting Agenda Items with ConnectWise Tickets
|
||||||
|
|
||||||
|
When a user provides a meeting agenda with client-specific items (locations, projects, systems), search ConnectWise for related tickets to surface existing work and avoid duplicates.
|
||||||
|
|
||||||
|
## Search Pattern
|
||||||
|
|
||||||
|
Use the `summary contains` condition on `/service/tickets`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
data, status = list_tickets(
|
||||||
|
conditions='summary contains "KEYWORD"',
|
||||||
|
page_size=200
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pitfalls
|
||||||
|
|
||||||
|
- **Keyword choice**: Search for the distinctive noun (location name, project name, system name) rather than generic terms like "setup" or "install". In the example below, the location keyword "Harborview" found the right ticket; "firewall" would have returned dozens.
|
||||||
|
- **False positives**: A broad keyword may match old closed tickets for other clients. Always inspect `company.name` and `site.name` to confirm relevance.
|
||||||
|
- **Status filter**: Do NOT pre-filter by `closedFlag=false` in the API query — the legacy mirror bug can omit current production tickets. Fetch all matches and filter client-side: exclude statuses starting with `>` (e.g., `>Closed`) and tickets with a `dateResolved` value.
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
**Agenda item**: "Harborview — Setup this month, 5 workstations, firewall install, WAN carrier needed, range <siteLAN>/24"
|
||||||
|
|
||||||
|
**Search**:
|
||||||
|
```python
|
||||||
|
data, status = list_tickets(conditions='summary contains "Harborview"', page_size=200)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Result**: Ticket `<ticketId>` — "Firewall for Harborview" (`<Client Name>`, `<Site>` site, Must Schedule, owner `<staff-initials>`, resources `<staff-initials>`, entered 2026-05-20).
|
||||||
|
|
||||||
|
This linked the agenda item directly to existing tracked work and surfaced that the ticket had been stalled in "Must Schedule" since May.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
After finding a candidate ticket, always read the full ticket (`get_ticket(id)`) to confirm:
|
||||||
|
1. `company.identifier` matches the expected client.
|
||||||
|
2. `site.name` matches the expected location.
|
||||||
|
3. `closedFlag` is false and `status.name` does not start with `>`.
|
||||||
|
4. `resources` or `owner` fields show who is assigned.
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# Ticket Time Entries, Notes & Status Changes — Working Payloads
|
||||||
|
|
||||||
|
Verified request/response shapes for documenting work on tickets via `scripts/cw_api.py`.
|
||||||
|
|
||||||
|
## Response shape normalization (do this FIRST in every script)
|
||||||
|
|
||||||
|
`cw_api.cw_request` returns `(data, http_status)` tuples, and list endpoints may wrap the payload one list deeper. Normalize before any `.get()` or loop, or scripts fail with `AttributeError: 'tuple' object has no attribute 'get'`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def norm(resp):
|
||||||
|
data = resp[0] if isinstance(resp, tuple) and len(resp) == 2 else resp
|
||||||
|
if isinstance(data, list) and data and isinstance(data[0], list):
|
||||||
|
data = data[0]
|
||||||
|
return [x for x in (data if isinstance(data, list) else [data]) if isinstance(x, dict)]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Create a time entry on a ticket
|
||||||
|
|
||||||
|
`POST /time/entries`. Convert local Pacific time to UTC by adding 7 hours (1:30–3:30 PM PDT → 20:30–22:30Z):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"company": {"id": <companyId>},
|
||||||
|
"member": {"id": 147},
|
||||||
|
"chargeToId": <ticketId>,
|
||||||
|
"chargeToType": "ServiceTicket",
|
||||||
|
"locationId": 11,
|
||||||
|
"businessUnitId": 1,
|
||||||
|
"workType": {"id": 3},
|
||||||
|
"workRole": {"id": 4},
|
||||||
|
"timeStart": "<ISO UTC>",
|
||||||
|
"timeEnd": "<ISO UTC>",
|
||||||
|
"actualHours": 2.0,
|
||||||
|
"billableOption": "Billable",
|
||||||
|
"notes": "<work note text>",
|
||||||
|
"addToDetailDescriptionFlag": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `addToDetailDescriptionFlag` does NOT produce a ticket note on this instance — post the note separately if the text must appear on the ticket.
|
||||||
|
- Verify with `GET /time/entries/{id}`: check `member.identifier`, `timeStart`/`timeEnd`, `actualHours`, and `notes` (top level; `_info.notes` only appears on the ticket timeEntries list view).
|
||||||
|
- `billableOption`: "Billable" or "NoCharge".
|
||||||
|
|
||||||
|
## Add a ticket note
|
||||||
|
|
||||||
|
`POST /service/tickets/{id}/notes`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"text": "<note text>",
|
||||||
|
"detailDescriptionFlag": true,
|
||||||
|
"internalAnalysisFlag": false,
|
||||||
|
"resolutionFlag": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Do NOT include `customerVisibleFlag` (or any field not on ServiceNote) — the API 400s the entire request: "Could not find member 'customerVisibleFlag' on object of type 'ServiceNote'".
|
||||||
|
- Notes are attributed to the API member (TinMan), not an arbitrary author.
|
||||||
|
- Verify with `GET /service/tickets/{id}/notes`.
|
||||||
|
|
||||||
|
## Change ticket status
|
||||||
|
|
||||||
|
1. Resolve the numeric status ID: `GET /service/boards/{boardId}/statuses`, match on `name` (VistaNet Help Desk board = 1; "In Progress" = id 1; names starting with `>` are closed-type statuses).
|
||||||
|
2. PATCH `/service/tickets/{id}` with a JSON Patch ARRAY:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[{"op": "replace", "path": "status/id", "value": 1}]
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Verify: `GET /service/tickets/{id}?fields=id,status`.
|
||||||
|
|
||||||
|
## Documentation audit of a scheduled day
|
||||||
|
|
||||||
|
```python
|
||||||
|
# UTC bounds: Pacific day 00:00–24:00 PDT → add 7 hours
|
||||||
|
params = {"conditions": f"member/id={memberId} AND dateStart >= [{startUtc}] AND dateStart <= [{endUtc}]",
|
||||||
|
"pageSize": 100}
|
||||||
|
entries = norm(cw_api.cw_request("GET", "schedule/entries", params=params))
|
||||||
|
# per entry: objectId = ticket id → GET /service/tickets/{id}/notes AND /service/tickets/{id}/timeEntries
|
||||||
|
# count only notes where note["member"]["identifier"] matches the member — time entries do NOT count as notes
|
||||||
|
# report per ticket: notes present / time-entry-only / nothing
|
||||||
|
```
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# ConnectWise Ticket Fetching Implementation
|
||||||
|
Implementation details for fetching tickets from the VistaNet ConnectWise instance.
|
||||||
|
|
||||||
|
## API Configuration
|
||||||
|
- **Base URL**: `https://cw.vistanetinc.com/v4_6_release/apis/3.0`
|
||||||
|
- **Authentication**: Basic Auth using `companyid+publickey:privatekey`.
|
||||||
|
- **Required Header**: `clientId` (Crucial: 401 Unauthorized is returned if this is missing or incorrect).
|
||||||
|
|
||||||
|
## Implementation Pattern
|
||||||
|
Since server-side filtering is unreliable, the reliable pattern is:
|
||||||
|
1. Fetch open tickets using `conditions=id >= [RecentID] AND closedFlag=false` with a large `pageSize`.
|
||||||
|
2. Filter JSON **locally in Python** for the target company by `company.id`.
|
||||||
|
3. (Optional) Filter for specific owner, board, or other criteria locally.
|
||||||
|
4. Filter out statuses starting with `>` (e.g., `>Completed`) to identify truly open tickets.
|
||||||
|
|
||||||
|
### Why local filtering is required
|
||||||
|
The `companyId` query parameter on `/service/tickets` is **silently ignored** by the API and returns tickets from all companies. Server-side `conditions` on identifiers also often return empty lists or incorrect results. Always fetch broad and filter client-side.
|
||||||
|
|
||||||
|
## Example: All open tickets for a specific company
|
||||||
|
```python
|
||||||
|
import base64, requests, json
|
||||||
|
|
||||||
|
BASE_URL = 'https://cw.vistanetinc.com/v4_6_release/apis/3.0'
|
||||||
|
CLIENT_ID = '<CLIENT_ID>'
|
||||||
|
COMPANY_ID = 'VNI'
|
||||||
|
PUBLIC_KEY = '<PUBLIC_KEY>'
|
||||||
|
PRIVATE_KEY = '<PRIVATE_KEY>'
|
||||||
|
|
||||||
|
auth_b64 = base64.b64encode(f'{COMPANY_ID}+{PUBLIC_KEY}:{PRIVATE_KEY}'.encode()).decode()
|
||||||
|
headers = {
|
||||||
|
'Authorization': f'Basic {auth_b64}',
|
||||||
|
'clientId': CLIENT_ID,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. Fetch broad — companyId is ignored, so we use id range to avoid legacy mirroring
|
||||||
|
url = f'{BASE_URL}/service/tickets?pageSize=1000&conditions=id%20%3E%3D%2080000%20AND%20closedFlag%3Dfalse'
|
||||||
|
resp = requests.get(url, headers=headers, timeout=45)
|
||||||
|
resp.raise_for_status()
|
||||||
|
tickets = resp.json()
|
||||||
|
|
||||||
|
# 2. Filter client-side for Miller Glass (company.id == 19502)
|
||||||
|
miller_tickets = [
|
||||||
|
t for t in tickets
|
||||||
|
if t.get('company', {}).get('id') == 19502
|
||||||
|
and not t.get('status', {}).get('name', '').startswith('>')
|
||||||
|
and t.get('dateResolved') is None
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## "Open" ticket definition
|
||||||
|
A ticket is considered open when ALL of the following are true:
|
||||||
|
- `closedFlag` is `false`
|
||||||
|
- Status name does NOT start with `>` (e.g., `>Closed`, `>Completed`)
|
||||||
|
- `dateResolved` is `None` or absent
|
||||||
|
|
||||||
|
## Searching ticket NOTES for text across a company's tickets
|
||||||
|
Note text is NOT searchable server-side on this instance: `POST /service/tickets/search` accepts `childconditions` in its FilterValues body but **silently ignores it** — verified that six different `childconditions` values (`notes/text contains '...'`) all returned identical result sets, with no error to warn you. Never conclude a note doesn't exist based on a childconditions query.
|
||||||
|
|
||||||
|
Working pattern (validated end-to-end, ~0.25 s/ticket):
|
||||||
|
1. Resolve the company: `GET /company/companies?conditions=name contains 'Partial'` → cache `company.id`.
|
||||||
|
2. Size the corpus: `GET /service/tickets/count?conditions=company/id={id}` (the count endpoint honors the condition, unlike the list endpoint's bare `companyId` param). Bound it with `AND dateEntered >= [YYYY-01-01T00:00:00Z]` when the ask is recency-scoped — the date condition is also honored and shrank a 4,301-ticket corpus to 1,086.
|
||||||
|
3. Page-fetch ticket ids: `GET /service/tickets?conditions=company/id={id} AND dateEntered >= [...]&pageSize=200&page={n}&orderBy=id asc&fields=id,summary,status/name,closedFlag,dateResolved` until a short page. Persist the array to a scratch JSON file between calls — kernel state does not survive a timeout.
|
||||||
|
4. Per ticket: `GET /service/tickets/{id}/notes?pageSize=1000` and regex the `text` client-side. pageSize=1000 on a single ticket's notes is safe; the corruption concern applies to large LIST fetches only.
|
||||||
|
5. Triage matches by snippet before reporting: generic keywords ('office', 'field') drown in "out of the office" autoresponder noise from email-logged notes — the signal is usually in `internalFlag=true` notes written by members. Email/contact notes carry `member: null`; read `contact` or the text for attribution.
|
||||||
|
|
||||||
|
Budget runtime: 1,000+ tickets of note fetches takes ~4–5 minutes — chunk the scan (~300 tickets per execute_code call) and append matches to a scratch JSON so a timeout loses nothing.
|
||||||
|
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import requests
|
||||||
|
import re
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import base64
|
||||||
|
from requests.auth import HTTPBasicAuth
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
BASE_URL = "https://cw.vistanetinc.com/v4_6_release/apis/3.0"
|
||||||
|
def _load_cw_client_id():
|
||||||
|
"""Resolve the ConnectWise clientId from env or ~/.secrets/connectwise."""
|
||||||
|
cid = os.getenv("CW_CLIENT_ID", "")
|
||||||
|
if cid:
|
||||||
|
return cid
|
||||||
|
path = os.path.expanduser("~/.secrets/connectwise")
|
||||||
|
if os.path.exists(path):
|
||||||
|
with open(path) as f:
|
||||||
|
for line in f:
|
||||||
|
if line.lstrip().startswith("CW_CLIENT_ID="):
|
||||||
|
return line.split("=", 1)[1].strip()
|
||||||
|
raise SystemExit(
|
||||||
|
"CW_CLIENT_ID missing: export CW_CLIENT_ID or add "
|
||||||
|
"CW_CLIENT_ID=<uuid> to ~/.secrets/connectwise"
|
||||||
|
)
|
||||||
|
|
||||||
|
CLIENT_ID = _load_cw_client_id()
|
||||||
|
SECRETS_PATH = os.path.expanduser("~/.secrets/connectwise")
|
||||||
|
|
||||||
|
def get_auth():
|
||||||
|
with open(SECRETS_PATH, 'r') as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
creds = {}
|
||||||
|
for line in lines:
|
||||||
|
if '=' in line:
|
||||||
|
k, v = line.strip().split('=', 1)
|
||||||
|
creds[k] = v
|
||||||
|
|
||||||
|
auth_str = f"{creds['CW_COMPANY_ID']}{creds['CW_PUBLIC_KEY']}:{creds['CW_PRIVATE_KEY']}"
|
||||||
|
return auth_str
|
||||||
|
|
||||||
|
def clean_json(text):
|
||||||
|
if not text:
|
||||||
|
return text
|
||||||
|
return re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f]', '', text)
|
||||||
|
|
||||||
|
def cw_request(method, endpoint, params=None, json_data=None):
|
||||||
|
url = f"{BASE_URL}/{endpoint.lstrip('/')}"
|
||||||
|
auth_str = get_auth()
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"clientId": CLIENT_ID,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded = base64.b64encode(auth_str.encode()).decode()
|
||||||
|
headers["Authorization"] = f"Basic {encoded}"
|
||||||
|
|
||||||
|
response = requests.request(
|
||||||
|
method=method,
|
||||||
|
url=url,
|
||||||
|
params=params,
|
||||||
|
json=json_data,
|
||||||
|
headers=headers,
|
||||||
|
timeout=30
|
||||||
|
)
|
||||||
|
|
||||||
|
cleaned_text = clean_json(response.text)
|
||||||
|
try:
|
||||||
|
return json.loads(cleaned_text), response.status_code
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return cleaned_text, response.status_code
|
||||||
|
|
||||||
|
def get_ticket(ticket_id):
|
||||||
|
return cw_request("GET", f"service/tickets/{ticket_id}")
|
||||||
|
|
||||||
|
def list_tickets(conditions=None, page_size=100):
|
||||||
|
params = {"pageSize": page_size}
|
||||||
|
if conditions:
|
||||||
|
params["conditions"] = conditions
|
||||||
|
return cw_request("GET", "service/tickets", params=params)
|
||||||
|
|
||||||
|
def update_ticket(ticket_id, patch_data):
|
||||||
|
return cw_request("PATCH", f"service/tickets/{ticket_id}", json_data=patch_data)
|
||||||
|
|
||||||
|
def create_schedule_entry(data):
|
||||||
|
return cw_request("POST", "schedule/entries", json_data=data)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("Testing CW Connection...")
|
||||||
|
data, status = get_ticket(121216)
|
||||||
|
print(f"Status: {status}")
|
||||||
|
print(f"Data: {data}")
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
---
|
||||||
|
name: connectwise-n8n-automation
|
||||||
|
description: Use when automating ConnectWise PSA tasks via n8n workflows.
|
||||||
|
---
|
||||||
|
|
||||||
|
# ConnectWise n8n Automation
|
||||||
|
|
||||||
|
Use when automating ConnectWise PSA tasks (fetching tickets, updating owners, summarizing notes) using n8n workflows via MCP.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
1. **Identify Workflow**: Search for existing workflows using `mcp__n8n__search_workflows`.
|
||||||
|
2. **Inspect Schema**: Use `mcp__n8n__get_workflow_details` to understand required input parameters (e.g., `username`, `ticketId`).
|
||||||
|
3. **Execute**: Use `mcp__n8n__execute_workflow`.
|
||||||
|
4. **Retrieve Results**:
|
||||||
|
- Use `mcp__n8n__get_execution` with `includeData: true`.
|
||||||
|
- If the result is large and truncated to a file (e.g., `/tmp/hermes-results/...`), use `read_file` and `execute_code`.
|
||||||
|
5. **Verification**: If a workflow is supposed to update data, verify the change by re-fetching the record.
|
||||||
|
|
||||||
|
## Pitfalls & Lessons
|
||||||
|
|
||||||
|
### 1. Parsing Truncated Tool Outputs
|
||||||
|
When `mcp__n8n__get_execution` returns a result too large for the context, Hermes saves it to a file. These files are **not** raw JSON; they are line-numbered (e.g., `1|{"result": ...}`).
|
||||||
|
- **Wrong**: `json.loads(file_content)`
|
||||||
|
- **Right**: `json_str = line.split('|', 1)[1].strip()` before parsing.
|
||||||
|
|
||||||
|
### 2. User Filtering Ambiguity
|
||||||
|
ConnectWise nodes in n8n may use different fields for "username".
|
||||||
|
- If filtering by `SBrown` (identifier) returns 0 results but you know tickets exist, try `Scott Brown` (full name).
|
||||||
|
- The workflow's internal query often uses `owner/name like ...`, which may require the human-readable name.
|
||||||
|
|
||||||
|
### 3. API Auth (n8n vs. Direct)
|
||||||
|
Direct API calls via `execute_code` (using `requests`) often fail with `401 Unauthorized` even with correct Public/Private keys because they lack the required `clientId` (UUID).
|
||||||
|
- **Guidance**: Always prefer existing n8n workflows for ConnectWise actions. If no workflow exists, create one using `mcp__n8n__create_workflow_from_code` to leverage n8n's managed credentials.
|
||||||
|
|
||||||
|
### 4. Long-Running LLM Workflows
|
||||||
|
Workflows utilizing AI (e.g., Ollama for summarization) take significantly longer than standard API la calls.
|
||||||
|
- **Action**: Poll `mcp__n8n__get_execution` multiple times or implement a wait loop in `execute_code` before attempting to read the final results.
|
||||||
|
|
||||||
|
### 5. MCP Server Access: Two Servers, Two Tokens, SSE Result Framing
|
||||||
|
- TWO n8n MCP servers exist: tallerhawk (token in ~/.hermes/.env as N8N_API_KEY) and n8n.vistanetinc.com (token ONLY in config.yaml under mcp_servers). The .env token returns `Unauthorized` against vistanetinc — pull the config.yaml one instead.
|
||||||
|
- Raw HTTP MCP works: POST to /mcp-server/http with Accept: 'application/json, text/event-stream'; responses are SSE frames. The RESULT frame is the first `data:` frame containing a `result` key (it often has NO `id` key — don't wait for id==1).
|
||||||
|
- search_workflow_executions: server retains only ~30 recent executions server-wide; `status` filter param is not accepted; filter by workflowId param works but also just filter client-side by scanning. `nextCursor` pagination exists but the retained window is tiny anyway.
|
||||||
|
- Direct API Auth & Headers: same as below
|
||||||
|
Direct API calls via `execute_code` (using `requests`) fail with `401 Unauthorized` if the `clientId` and `companyId` are missing from the headers, even if Basic Auth is correct.
|
||||||
|
- **Required Headers**:
|
||||||
|
- `clientId`: UUID of the client application.
|
||||||
|
- `companyId`: The company identifier (e.g., `VNI`).
|
||||||
|
- **Auth String Format**: The username must be constructed as `companyid+publickey`. If the public key already starts with a `+`, do not add an additional `+` between the company ID and the key (to avoid the "double-plus bug").
|
||||||
|
- **Correct Base64 Pattern**: `base64(companyid+publickey:privatekey)`.
|
||||||
|
- **Correction**: Ensure both headers are present alongside Basic Auth. If standard `auth=(user, pass)` parameters fail, manually construct the `Authorization: Basic <base64>` header.
|
||||||
|
|
||||||
|
### 7. Trigger-Specific Data Mapping (MCP vs Webhook)
|
||||||
|
When triggering a workflow via the `execute_workflow` MCP tool, input data is injected at the root level, whereas real HTTP requests put data under the `body` object.
|
||||||
|
- **Pitfall**: An n8n workflow starting with a Webhook trigger will see an empty `$json.body` when executed via MCP, causing downstream nodes to fail or receive `undefined`.
|
||||||
|
- **Fix**: Add a **Set (Edit Fields)** node immediately after the trigger to normalize the data using nullish coalescing:
|
||||||
|
- `ticketId` $\rightarrow$ `{{ $json.body?.ticketId ?? $json.ticketId }}`
|
||||||
|
- `memberId` $\rightarrow$ `{{ $json.body?.memberId ?? $json.memberId }}`
|
||||||
|
- **Result**: Downstream nodes can now safely use `{{ $json.ticketId }}` regardless of how the workflow was triggered.
|
||||||
|
|
||||||
|
### 8. PSA Release Notes Are Not Programmatically Reachable
|
||||||
|
docs.connectwise.com's PSA release-notes tree is SSO-gated and blocks anonymous datacenter IPs at the CDN edge (every fetch returns a login interstitial; Wayback/archive.today hold zero captures). When asked what a release contains: verify CVE/security-bulletin facts from public bulletins first — those ARE fetchable — then route functional release-note questions to the user (ConnectWise Home release-notes widget on their logged-in console) instead of cycling anonymous recovery ladders. For a full pull, ask the user to save partner-portal credentials to the vault so the docs site can be logged into.
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
---
|
||||||
|
name: connectwise-open-tickets-report
|
||||||
|
description: Generate an accurate Open Tickets report from ConnectWise PSA targeting the VistaNet Help Desk board.
|
||||||
|
---
|
||||||
|
|
||||||
|
# ConnectWise Open Tickets Report
|
||||||
|
|
||||||
|
This skill defines the precise requirements for generating the "Open Tickets" report from the ConnectWise PSA API.
|
||||||
|
|
||||||
|
## Trigger Conditions
|
||||||
|
- User requests the "Open Tickets Report".
|
||||||
|
- A cron job is scheduled to monitor open tickets.
|
||||||
|
|
||||||
|
## Technical Specifications
|
||||||
|
- **API Endpoint**: `https://cw.vistanetinc.com/v4_6_release/apis/3.0/service/tickets`
|
||||||
|
- **Authentication**: Credentials (Company ID, Public Key, Private Key, and Client ID) must be read from `~/.secrets/connectwise` at runtime.
|
||||||
|
- **Auth Header Pattern**: The API typically requires `Authorization: Basic base64(clientId:privateKey)`. Ensure the `clientId` is exactly as provided (check for missing characters in environment variables vs secret files) and that `Company-Id` is passed as a separate header.
|
||||||
|
- **Critical Reference Syntax**: Use forward-slash notation for sub-references in the URL (e.g., `owner/identifier='SBrown'` and `board/id=1`). Do NOT use dot-notation (`owner.identifier`), as this returns a 400 Bad Request.
|
||||||
|
- **Client ID**: Use the value found in `connectwise_reference.md` (or `/home/tech/connectwise_reference.md`). The API requires this as a separate header.
|
||||||
|
- **Auth String Format**: Use `Basic base64(companyid+publickey:privatekey)` for the Authorization header. The literal `+` between companyid and publickey is the separator and must be present. If the public key itself begins with `+`, do not add an extra separator (verify the concatenated result does not produce `++`).
|
||||||
|
- **Client ID Header**: The API requires a `clientId` header (distinct from the auth string). Refer to `connectwise_reference.md` for the current Client ID. Ensure the secret file includes the `CLIENT_ID` if required by the runtime endpoint.
|
||||||
|
- **Conditions Logic**:
|
||||||
|
- **Authentication**: Credentials (Company ID, Public Key, Private Key, and Client ID) must be read from `~/.secrets/connectwise` at runtime.
|
||||||
|
- **Auth Header Pattern**: The API typically requires `Authorization: Basic base64(clientId:privateKey)`. Ensure the `clientId` is exactly as provided (check for missing characters in environment variables vs secret files) and that `Company-Id` is passed as a separate header.
|
||||||
|
- **Critical Reference Syntax**: Use forward-slash notation for sub-references in the URL (e.g., `owner/identifier='SBrown'` and `board/id=1`). Do NOT use dot-notation (`owner.identifier`), as this returns a 400 Bad Request.
|
||||||
|
- **Standard Filter**: `closedFlag=false` must be included to isolate open tickets.
|
||||||
|
- **Status Filter**: Exclude tickets where status starts with `>` (e.g., `>Completed`).
|
||||||
|
- `status != '>Completed'` (Filter in Python or URL).
|
||||||
|
|
||||||
|
## Delivery & Formatting
|
||||||
|
- **Himalaya CLI Syntax**: The `himalaya send` command does not exist. Use `himalaya message send` and provide the raw email (including headers) via stdin or a file.
|
||||||
|
- **Critical Pitfall (Himalaya)**: `himalaya message send` frequently panics (`index out of bounds`) when processing raw email files. To avoid this, pipe the raw email string (including `From:`, `To:`, and `Subject:`) directly to the command via stdin.
|
||||||
|
- **Format**: Professional formatted text table or HTML.
|
||||||
|
- **Notification**: Report results to the origin chat upon completion.
|
||||||
|
|
||||||
|
## Execution Workflow
|
||||||
|
1. Construct the API request using `closedFlag=false` and the `owner/identifier` condition.
|
||||||
|
2. Fetch tickets with a sufficiently large `pageSize` (e.g., 1000).
|
||||||
|
3. Perform post-filtering in Python for criteria that cannot be reliably expressed in the API conditions (e.g., specific status name patterns like `NOT LIKE '>Completed'`).
|
||||||
|
4. For SLA evaluation, inspect `isInSla`, `respondMinutes`, `resPlanMinutes`, `resolveMinutes`, and `slaStatus` on each ticket record.
|
||||||
|
5. For ticket status updates, use JSON Patch format (`[{"op": "replace", "path": "/status/id", "value": <id>}]`) against `PATCH /service/tickets/{id}`.
|
||||||
|
6. Format the output into a clean table containing: Ticket ID, Summary, Status, Company, and Date Entered.
|
||||||
|
|
||||||
|
## Ticket Updates
|
||||||
|
- **JSON Patch Format Required**: CW v3 API uses RFC 6902 JSON Patch for `PATCH` requests. Do NOT send a flat JSON body.
|
||||||
|
- Correct: `[{"op": "replace", "path": "/status/id", "value": 425}]`
|
||||||
|
- Wrong: `{"status": {"id": 425}}` (returns 400)
|
||||||
|
- **Status IDs are Board-Scoped**: The same status name may have different IDs on different boards. Always look up the status on the ticket's actual board (`/service/boards/{boardId}/statuses`) before patching.
|
||||||
|
- **Self-Healed Status**: On the "Managed Service Alerts" board (ID 6), status "Self-Healed" = ID 425. Use this for bulk-closing stale RMM alerts.
|
||||||
|
|
||||||
|
## SLA Evaluation
|
||||||
|
Key ticket fields for SLA analysis:
|
||||||
|
- `isInSla` (bool): Whether the ticket is currently within SLA
|
||||||
|
- `slaStatus` (string): Human-readable SLA text (e.g., "Respond by Tue 04/21 3:37 PM UTC-07")
|
||||||
|
- `respondMinutes` (int): Minutes since ticket entered "respond" SLA window
|
||||||
|
- `resPlanMinutes` (int): Minutes since ticket entered "resplan" SLA window
|
||||||
|
- `resolveMinutes` (int): Minutes since ticket entered "resolve" SLA window
|
||||||
|
- `escalationLevel` (int): Escalation tier (higher = more critical)
|
||||||
|
- `priority.level`: Critical/High/Medium/Low
|
||||||
|
- `severity` and `impact`: String values (Critical/High/Medium/Low)
|
||||||
|
|
||||||
|
See `references/sla_evaluation.md` for a full SLA scoring recipe.
|
||||||
|
|
||||||
|
## RMM Alert Noise Pattern
|
||||||
|
- The "Managed Service Alerts" board (ID 6) auto-generates Priority 1 tickets from Sophos Central RMM.
|
||||||
|
- Typical patterns: "Firewall has not contacted Sophos Central", "Gateway 'X' is Down", "Real time protection disabled".
|
||||||
|
- These often create hundreds of unassigned P1s that are never triaged. A periodic bulk review (status `"new from RMM"` >7 days old → `"Self-Healed"`) is standard maintenance.
|
||||||
|
- The single malware detection ticket (`C2/Generic-A`) should NEVER be auto-closed without human review.
|
||||||
|
|
||||||
|
## Execution Workflow
|
||||||
|
1. Construct the API request using `closedFlag=false` and the `owner/identifier` condition.
|
||||||
|
2. Fetch tickets with a sufficiently large `pageSize` (e.g., 1000).
|
||||||
|
3. Perform post-filtering in Python for criteria that cannot be reliably expressed in the API conditions (e.g., specific status name patterns like `NOT LIKE '>Completed'`).
|
||||||
|
4. For SLA evaluation, inspect `isInSla`, `respondMinutes`, `resPlanMinutes`, `resolveMinutes`, and `slaStatus` on each ticket record.
|
||||||
|
5. For ticket status updates, use JSON Patch format (`[{"op": "replace", "path": "/status/id", "value": <id>}]`) against `PATCH /service/tickets/{id}`.
|
||||||
|
6. Format the output into a clean table containing: Ticket ID, Summary, Status, Company, and Date Entered.
|
||||||
|
|
||||||
|
## Ticket Updates
|
||||||
|
- **JSON Patch Format Required**: CW v3 API uses RFC 6902 JSON Patch for `PATCH` requests. Do NOT send a flat JSON body.
|
||||||
|
- Correct: `[{"op": "replace", "path": "/status/id", "value": 425}]`
|
||||||
|
- Wrong: `{"status": {"id": 425}}` (returns 400)
|
||||||
|
- **Status IDs are Board-Scoped**: The same status name may have different IDs on different boards. Always look up the status on the ticket's actual board (`/service/boards/{boardId}/statuses`) before patching.
|
||||||
|
- **Self-Healed Status**: On the "Managed Service Alerts" board (ID 6), status "Self-Healed" = ID 425. Use this for bulk-closing stale RMM alerts.
|
||||||
|
|
||||||
|
## SLA Evaluation
|
||||||
|
Key ticket fields for SLA analysis:
|
||||||
|
- `isInSla` (bool): Whether the ticket is currently within SLA
|
||||||
|
- `slaStatus` (string): Human-readable SLA text (e.g., "Respond by Tue 04/21 3:37 PM UTC-07")
|
||||||
|
- `respondMinutes` (int): Minutes since ticket entered "respond" SLA window
|
||||||
|
- `resPlanMinutes` (int): Minutes since ticket entered "resplan" SLA window
|
||||||
|
- `resolveMinutes` (int): Minutes since ticket entered "resolve" SLA window
|
||||||
|
- `escalationLevel` (int): Escalation tier (higher = more critical)
|
||||||
|
- `priority.level`: Critical/High/Medium/Low
|
||||||
|
- `severity` and `impact`: String values (Critical/High/Medium/Low)
|
||||||
|
|
||||||
|
See `references/sla_evaluation.md` for a full SLA scoring recipe.
|
||||||
|
|
||||||
|
## RMM Alert Noise Pattern
|
||||||
|
- The "Managed Service Alerts" board (ID 6) auto-generates Priority 1 tickets from Sophos Central RMM.
|
||||||
|
- Typical patterns: "Firewall has not contacted Sophos Central", "Gateway 'X' is Down", "Real time protection disabled".
|
||||||
|
- These often create hundreds of unassigned P1s that are never triaged. A periodic bulk review (status `"new from RMM"` >7 days old → `"Self-Healed"`) is standard maintenance.
|
||||||
|
- The single malware detection ticket (`C2/Generic-A`) should NEVER be auto-closed without human review.
|
||||||
|
|
||||||
|
## Pitfalls & Lessons
|
||||||
|
- **URL Condition Syntax**: Use forward-slash notation for sub-references in URL `conditions` (e.g., `owner/identifier='SBrown'`, `board/id=1`) rather than dot notation. Dot notation often triggers 400 Bad Request errors.
|
||||||
|
- **Date Source**: Top-level `dateEntered` on the ticket record may be absent or unreliable. Use `_info.dateEntered` from the embedded `_info` object — this is the canonical timestamp.
|
||||||
|
- **Himalaya CLI Syntax**: The `himalaya send` command does not exist. Use `himalaya message send` and provide the raw email (including headers) via stdin or a file.
|
||||||
|
- **Critical Pitfall (Himalaya)**: `himalaya message send` frequently panics (`index out of bounds`) when processing raw email files. To avoid this, pipe the raw email string (including `From:`, `To:`, and `Subject:`) directly to the command via stdin.
|
||||||
|
- **500 Error "Length cannot be less than zero"**: If this error occurs, it typically indicates an authentication failure or an incorrectly formatted request that bypassed the 401 layer. Verify that the `Authorization` header follows the `companyid+publickey:privatekey` format and a valid `clientId` is present.
|
||||||
|
- **Authorization Header**: Ensure the Base64 string follows the `companyid+publickey:privatekey` format exactly, where `+` is the literal separator. If the public key itself begins with `+`, do not add an extra separator. Simple `companyid:publickey:privatekey` (colon between all three) will result in a 401 Unauthorized error.
|
||||||
|
- **Client ID**: The `clientId` must be passed as a separate header, not just part of the auth string.
|
||||||
|
- **Pandas Dependency**: The helper script `scripts/generate_open_tickets.py` depends on `pandas`. In cron/headless environments where `pandas` is missing, implement manual HTML table generation using standard Python lists to avoid dependency errors.
|
||||||
|
- **RTF Report Extraction**: When processing Nexpose/Qualys RTF reports, standard tools (`striprtf`, `unrtf`, `catdoc`) fail because these files contain embedded binary objects (`\shp`, `\pict`, `\bin`). Use `strings -n 15` plus targeted grep, or a custom byte-level parser that skips destination groups. See the `document-extraction-workflows` skill for a reusable Python extractor.
|
||||||
|
- **SMTP for Rich Email**: When `himalaya` cannot handle HTML attachments or complex MIME, fall back to Python `smtplib` with `email.mime.multipart`. Parse credentials from `~/.config/himalaya/config.toml` if available.
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# ConnectWise SLA Evaluation Recipe
|
||||||
|
|
||||||
|
Reference: `sla_evaluation.md` — How to evaluate SLA state and score tickets for prioritization using the CW v3 API.
|
||||||
|
|
||||||
|
## Fields to Read
|
||||||
|
|
||||||
|
| Field | Type | Meaning |
|
||||||
|
|-------|------|---------|
|
||||||
|
| `isInSla` | bool | False = breached |
|
||||||
|
| `slaStatus` | string | e.g. "Respond by Tue 04/21 3:37 PM UTC-07" |
|
||||||
|
| `respondMinutes` | int | Minutes in "respond" phase |
|
||||||
|
| `resPlanMinutes` | int | Minutes in "resplan" phase |
|
||||||
|
| `resolveMinutes` | int | Minutes in "resolve" phase |
|
||||||
|
| `escalationLevel` | int | Tier (higher = worse) |
|
||||||
|
| `priority.level` | string | Critical / High / Medium / Low |
|
||||||
|
| `severity` | string | Critical / High / Medium / Low |
|
||||||
|
| `impact` | string | Critical / High / Medium / Low |
|
||||||
|
| `_info.dateEntered` | ISO string | Canonical creation time |
|
||||||
|
|
||||||
|
## Composite Scoring Formula
|
||||||
|
|
||||||
|
```python
|
||||||
|
score = 0
|
||||||
|
|
||||||
|
if sla_overdue:
|
||||||
|
score += 1000
|
||||||
|
elif sla_state == 'Due <24h':
|
||||||
|
score += 500
|
||||||
|
elif sla_state == 'Due <72h':
|
||||||
|
score += 200
|
||||||
|
|
||||||
|
score += (escalation or 0) * 50
|
||||||
|
|
||||||
|
prio_map = {
|
||||||
|
'Priority 1 - Critical': 400,
|
||||||
|
'Priority 2 - High': 200,
|
||||||
|
'Priority 3 - Medium': 50,
|
||||||
|
'Priority 4 - Low': 10
|
||||||
|
}
|
||||||
|
score += prio_map.get(priority_name, 0)
|
||||||
|
|
||||||
|
if severity in ('Critical', 'High'):
|
||||||
|
score += 100
|
||||||
|
if impact in ('Critical', 'High'):
|
||||||
|
score += 100
|
||||||
|
|
||||||
|
if date_entered:
|
||||||
|
age_days = (now - date_entered).total_seconds() / 86400
|
||||||
|
score += min(int(age_days) * 5, 300)
|
||||||
|
```
|
||||||
|
|
||||||
|
## SLA State Parsing
|
||||||
|
|
||||||
|
```python
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
def parse_sla(sla_status_text):
|
||||||
|
if not sla_status_text or sla_status_text == 'Resolved':
|
||||||
|
return sla_status_text or 'No SLA'
|
||||||
|
m = re.search(r'(Respond|Resolve)\s+by\s+(.+)', sla_status_text, re.IGNORECASE)
|
||||||
|
if m:
|
||||||
|
due_text = m.group(2).strip()
|
||||||
|
try:
|
||||||
|
dt = datetime.strptime(due_text, '%a %m/%d %I:%M %p %Z%z')
|
||||||
|
if dt < now:
|
||||||
|
return 'Overdue'
|
||||||
|
hours = (dt - now).total_seconds() / 3600
|
||||||
|
if hours < 24:
|
||||||
|
return 'Due <24h'
|
||||||
|
elif hours < 72:
|
||||||
|
return 'Due <72h'
|
||||||
|
return 'On Track'
|
||||||
|
except ValueError:
|
||||||
|
return 'Due (unparsed)'
|
||||||
|
return sla_status_text
|
||||||
|
```
|
||||||
|
|
||||||
|
## RMM Alert Categories (for filtering / triage)
|
||||||
|
|
||||||
|
Keyword-based categorization of common Sophos/RMM-generated summaries:
|
||||||
|
|
||||||
|
| Category | Keywords |
|
||||||
|
|----------|----------|
|
||||||
|
| Firewall Sophos Offline | `firewall` + `sophos` |
|
||||||
|
| Firewall Heartbeat | `not sending heartbeat` |
|
||||||
|
| Gateway Down | `gateway` + `down` |
|
||||||
|
| Malware Detection | `malicious`, `c2/` |
|
||||||
|
| Device Offline | `has not contacted` |
|
||||||
|
| Disk/Storage | `disk`, `drive`, `storage`, `c:` |
|
||||||
|
| CPU Alert | `cpu` |
|
||||||
|
| Memory Alert | `memory`, `ram` |
|
||||||
|
| Reboot Required | `reboot`, `restart` |
|
||||||
|
| AV Alert | `antivirus`, `virus` |
|
||||||
|
|
||||||
|
## Bulk Close Recipe
|
||||||
|
|
||||||
|
Update stale "new from RMM" tickets to "Self-Healed":
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 1. Fetch all open tickets
|
||||||
|
# 2. Filter: status == 'new from RMM' AND _info.dateEntered > 7 days ago
|
||||||
|
# 3. PATCH each: [{"op": "replace", "path": "/status/id", "value": 425}]
|
||||||
|
# Status ID 425 = "Self-Healed" on board 6 (Managed Service Alerts)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Never auto-close malware detections without human review.**
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# ConnectWise PSA Ticket Report Logic
|
||||||
|
Reference for the `cw_open_tickets.py` script logic.
|
||||||
|
|
||||||
|
## Filtering Logic
|
||||||
|
- **URL Condition**: `closedFlag = false` (Must be URL-encoded).
|
||||||
|
- **Post-Processing**:
|
||||||
|
- `owner.identifier == 'SBrown'`
|
||||||
|
- `board.name == 'VistaNet Help Desk'`
|
||||||
|
- (Optional) `status.name != '>Completed'`
|
||||||
|
|
||||||
|
## Production Notes
|
||||||
|
- Historically, `id >= 110000` was required to avoid the 2011 legacy mirror. Current implementation prioritizes `closedFlag=false`.
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import base64
|
||||||
|
import requests
|
||||||
|
import pandas as pd
|
||||||
|
from datetime import datetime
|
||||||
|
import sys
|
||||||
|
|
||||||
|
def generate_report(owner_id, company_id, public_key, private_key, client_id):
|
||||||
|
base_url = 'https://cw.vistanetinc.com/v4_6_release/apis/3.0'
|
||||||
|
|
||||||
|
# Auth Format: Basic base64(companyidpublickey:privatekey)
|
||||||
|
auth_str = f"{company_id}{public_key}:{private_key}"
|
||||||
|
encoded_auth = base64.b64encode(auth_str.encode()).decode()
|
||||||
|
headers = {
|
||||||
|
'Authorization': f'Basic {encoded_auth}',
|
||||||
|
'clientId': client_id,
|
||||||
|
'Company-Id': company_id,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
|
||||||
|
conditions = f"closedFlag=false and owner/identifier='{owner_id}'"
|
||||||
|
url = f"{base_url}/service/tickets?conditions={conditions}"
|
||||||
|
|
||||||
|
response = requests.get(url, headers=headers)
|
||||||
|
if response.status_code != 200:
|
||||||
|
print(f"Error: API request failed with status {response.status_code}: {response.text}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
tickets = response.json()
|
||||||
|
if not tickets:
|
||||||
|
return f"No open tickets found for {owner_id}."
|
||||||
|
|
||||||
|
data = []
|
||||||
|
for t in tickets:
|
||||||
|
status_name = t.get('status', {}).get('name', 'Unknown') if isinstance(t.get('status'), dict) else t.get('status', 'Unknown')
|
||||||
|
if status_name.startswith('>'):
|
||||||
|
continue
|
||||||
|
|
||||||
|
data.append({
|
||||||
|
'Ticket #': t.get('id', 'N/A'),
|
||||||
|
'Company': t.get('company', {}).get('name', 'N/A') if isinstance(t.get('company'), dict) else t.get('company', 'N/A'),
|
||||||
|
'Owner': t.get('owner', {}).get('identifier', 'N/A') if isinstance(t.get('owner'), dict) else t.get('owner', 'N/A'),
|
||||||
|
'Status': status_name,
|
||||||
|
'Summary': t.get('summary', 'No Summary'),
|
||||||
|
'Date Entered': t.get('dateEntered', 'N/A')
|
||||||
|
})
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
return f"No open tickets found for {owner_id} (excluding completed statuses)."
|
||||||
|
|
||||||
|
df = pd.DataFrame(data).sort_values(by='Date Entered', ascending=False)
|
||||||
|
|
||||||
|
return f'''
|
||||||
|
<div style="font-family: Arial, Helvetica, sans-serif; color: #333;">
|
||||||
|
<h3 style="color: #0056b3;">Open Tickets Report: {owner_id}</h3>
|
||||||
|
<p>Generated on {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</p>
|
||||||
|
{df.to_html(index=False, border=1, justify='left')}
|
||||||
|
</div>
|
||||||
|
'''
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# This script is intended to be used by the agent providing credentials from a secret store
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print("Usage: generate_open_tickets.py <owner_id>")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Note: Actual credentials should be passed via env or a wrapper;
|
||||||
|
# the agent should read from ~/.secrets/connectwise
|
||||||
|
print("Error: This script requires a wrapper to inject credentials from ~/.secrets/connectwise")
|
||||||
|
sys.exit(1)
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
---
|
||||||
|
name: connectwise-open-tickets-robust
|
||||||
|
description: Use when API returns old/empty results. Robust ticket fetch.
|
||||||
|
---
|
||||||
|
|
||||||
|
# ConnectWise Open Tickets (Robust)
|
||||||
|
|
||||||
|
This skill provides a reliable method for fetching open tickets from the ConnectWise PSA API, specifically addressing the common issue where the API returns the oldest tickets first, potentially masking active tickets in a large database.
|
||||||
|
|
||||||
|
## Trigger Conditions
|
||||||
|
- User requests an "Open Tickets" report for themselves or another technician.
|
||||||
|
- Previous reports returned 0 results despite known open tickets.
|
||||||
|
|
||||||
|
## Technical Specifications
|
||||||
|
- **API Endpoint**: `https://cw.vistanetinc.com/v4_6_release/apis/3.0/service/tickets`
|
||||||
|
- **Authentication**: Use the `Authorization: Basic base64(companyid+publickey:privatekey)` header and a separate `clientId` header.
|
||||||
|
- **The "Oldest-First" Pitfall**: A simple query for `closedFlag=false` may return 1,000 results starting from the very first tickets created in the system (many years ago), which may all be closed, leading to an empty result set for newer open tickets.
|
||||||
|
|
||||||
|
## Execution Workflow
|
||||||
|
1. **Initial Fetch**: Attempt a query with `closedFlag=false` and `pageSize=1000`.
|
||||||
|
2. **Staleness Check**: If the returned tickets are all `closedFlag: true` or the ID range is very old (e.g., IDs < 20,000), the API is likely returning the oldest records first.
|
||||||
|
3. **Robust Recovery**: Pivot to an ID-based query. Fetch tickets with `id > [Current_Estimated_Floor]` (e.g., `id > 110000` or `id > 119000`).
|
||||||
|
4. **Refined Filtering**:
|
||||||
|
- Ensure `closedFlag == false`.
|
||||||
|
- Exclude any status starting with `>` (e.g., `>Completed`).
|
||||||
|
- Filter by the specific `owner/identifier` requested.
|
||||||
|
5. **Formatting**:
|
||||||
|
- Generate a professional HTML table.
|
||||||
|
- Include: ID, Summary, Status, Company, Date Entered, and SLA Status.
|
||||||
|
- **SLA Color Coding**: Green for `isInSla: true`, Red for `isInSla: false`.
|
||||||
|
6. **Delivery**: Pipe the raw HTML email (including headers) to `himalaya message send -`.
|
||||||
|
|
||||||
|
## Pitfalls & Lessons
|
||||||
|
- **dateEntered is NULL on ticket objects** in this deployment (v4_6, seen 2026-09-22) — even on single-ticket GETs. The real entry date lives in the `_info` block: `t['_info']['dateEntered']` (also `enteredBy`, `lastUpdated`, `updatedBy`). Filtering/conditioning on `dateEntered` in list conditions silently matches nothing.
|
||||||
|
- **`slaStatus` is a plain string** (e.g. "Respond by Fri 09/11 3:10 PM UTC-07"), NOT an object with `.name`. `hoursToSla`/`dateResponded` may also be null. `isInSla` (bool) is the reliable SLA filter.
|
||||||
|
- **Do NOT** rely on `closedFlag` as the only filter if the database is large; always verify the ID range of the returned results.
|
||||||
|
- **Ordering**: If `sort=dateEntered DESC` is not supported or ignored by the API, ID-based filtering is the only reliable way to reach recent tickets.
|
||||||
|
- **Himalaya Error**: Avoid using files for `himalaya message send` if you encounter `index out of bounds` panics; use stdin piping instead.
|
||||||
|
|
||||||
|
## Ticket Notes & Activity API (learned 2026-09-10)
|
||||||
|
- Notes endpoint: `GET /service/tickets/{id}/notes`. Field is **`dateCreated`**, NOT `dateEntered` — filtering on `dateEntered` silently matches nothing. `orderBy=dateEntered` is rejected 400 InvalidOrderBy on this endpoint; sort client-side.
|
||||||
|
- Recent activity often lives in **time entries, not notes**: `GET /time/entries?conditions=(ticket/id = {id})` — time entries DO use `dateEntered` and have `member.identifier` and `notes`. Check both when asked "any recent activity".
|
||||||
|
- Contact-authored notes (email-to-ticket) have **no `member` object** — only `contact`. Skip them (and skip the owner's own notes) when counting other-technician activity.
|
||||||
|
- Automated "VNI Bot" notes are a member but not a technician — report them separately from human tech activity.
|
||||||
|
- A recent-activity ticket may still return 0 notes and 0 time entries yet show `dateResponded`/`slaStatus` on the ticket detail — ticket detail carries NO `lastUpdate` object in this deployment (v4_6).
|
||||||
|
|
||||||
|
## Changing Ticket Owner via API (learned 2026-09-10)
|
||||||
|
- `PATCH /service/tickets/{id}` requires a **JSON Patch array**: `[{"op": "replace", "path": "owner/id", "value": <memberId>}]` — a plain `{"owner": {"id": N}}` body fails 400 "not in the expected array format".
|
||||||
|
- `/system/members` is **403** for the standard API key (no security permission). To get a member's numeric id, read any of their notes/ticket objects where `member: {id, identifier, name}` is embedded.
|
||||||
|
- Always verify assignments by re-reading the ticket: `owner.identifier` should match the target (SBrown=147, JGrigg=176).
|
||||||
|
|
||||||
|
## Scheduling Ticket Work Blocks (learned 2026-09-10)
|
||||||
|
Used for the cert-renewal playbook: schedule expiry-minus-7-days, 1h, weekend expiries shift to preceding Friday, stagger same-day blocks by 1h.
|
||||||
|
- Endpoint: `POST /schedule/entries`. Link to ticket via `objectId` (query existing with `(objectId = {id})`).
|
||||||
|
- Minimal working body: `{name, objectId, member:{id}, where:{id}, dateStart, dateEnd, reminder:{id}, status:{id}, type:{id}, span:{id}, hours}` (all times UTC). Returns 201 with created entry.
|
||||||
|
- Known-good values: member SBrown=147 / JGrigg=176 / JWiest=149; where Remote=4; status Firm=2; type id=4 (identifier 'S'); span id=1 ('N' normal block) or 2 ('B' auto-assign marker); reminder 15min=4.
|
||||||
|
- Auto-assign entries have NO date fields (hours 0.0) — markers only. A dated template entry (has dateStart/dateEnd) is the POST template. Query dated entries with `(dateStart > [YYYY-MM-DDT00:00:00Z])`.
|
||||||
|
- Duplicate check first: filter existing entries by objectId for any with `dateStart` before POSTing; skip if present. A ticket can legitimately need TWO blocks when its summary cert (e.g. GlobalSign Apr 2027) differs from its live cert (e.g. Let's Encrypt Oct 2026) — check what the existing block is for before skipping.
|
||||||
|
- After scheduling, flip ticket status via JSON Patch: `[{"op":"replace","path":"status/id","value":13}]` (13 = 'Scheduled' on board 1). Verify by re-reading the ticket AND the schedule entry.
|
||||||
|
- Working script: `scripts/schedule_cert_renewals.py` (edit TICKETS list; PATCH_STATUS=True also flips ticket status).
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import os
|
||||||
|
import requests, base64, json, datetime, re
|
||||||
|
|
||||||
|
# Generic ConnectWise cert-renewal scheduler.
|
||||||
|
# Usage: edit TICKETS list, run. Schedule = expiry minus 7 days;
|
||||||
|
# Sat/Sun shifts to preceding Friday; 9am PDT start, staggered +1h per same-day block.
|
||||||
|
# Set PATCH_STATUS=True to flip tickets to 'Scheduled' (status id 13) after entry creation.
|
||||||
|
|
||||||
|
def _load_creds():
|
||||||
|
"""Load ConnectWise API creds from ~/.secrets/connectwise or env."""
|
||||||
|
path = os.path.expanduser("~/.secrets/connectwise")
|
||||||
|
keys = {}
|
||||||
|
if os.path.exists(path):
|
||||||
|
with open(path) as f:
|
||||||
|
for line in f:
|
||||||
|
if "=" in line and not line.lstrip().startswith("#"):
|
||||||
|
k, v = line.strip().split("=", 1)
|
||||||
|
keys[k.strip()] = v.strip()
|
||||||
|
for k in ("CW_COMPANY_ID", "CW_PUBLIC_KEY", "CW_PRIVATE_KEY", "CW_CLIENT_ID"):
|
||||||
|
if os.getenv(k):
|
||||||
|
keys[k] = os.environ[k]
|
||||||
|
missing = [
|
||||||
|
k for k in ("CW_COMPANY_ID", "CW_PUBLIC_KEY", "CW_PRIVATE_KEY", "CW_CLIENT_ID")
|
||||||
|
if not keys.get(k)
|
||||||
|
]
|
||||||
|
if missing:
|
||||||
|
raise SystemExit(
|
||||||
|
f"missing {missing}: export them or populate ~/.secrets/connectwise"
|
||||||
|
)
|
||||||
|
return keys
|
||||||
|
|
||||||
|
|
||||||
|
_CREDS = _load_creds()
|
||||||
|
COMPANY_ID = _CREDS["CW_COMPANY_ID"]
|
||||||
|
PUBLIC_KEY = _CREDS["CW_PUBLIC_KEY"].lstrip("+")
|
||||||
|
PRIVATE_KEY = _CREDS["CW_PRIVATE_KEY"]
|
||||||
|
CLIENT_ID = _CREDS["CW_CLIENT_ID"]
|
||||||
|
|
||||||
|
BASE = "https://cw.vistanetinc.com/v4_6_release/apis/3.0"
|
||||||
|
MEMBER_ID = 147 # SBrown
|
||||||
|
WHERE_ID = 4 # Remote
|
||||||
|
SCHED_STATUS_ID = 2 # Firm
|
||||||
|
SCHED_TYPE_ID = 4 # 'S'
|
||||||
|
SPAN_ID = 1 # normal block
|
||||||
|
REMINDER_ID = 4 # 15 minutes
|
||||||
|
TICKET_STATUS_SCHEDULED = 13
|
||||||
|
PATCH_STATUS = False # True -> also PATCH ticket status to Scheduled
|
||||||
|
|
||||||
|
auth = base64.b64encode(f"{COMPANY_ID}+{PUBLIC_KEY}:{PRIVATE_KEY}".encode()).decode()
|
||||||
|
H = {"Authorization": f"Basic {auth}", "clientId": CLIENT_ID, "Content-Type": "application/json"}
|
||||||
|
|
||||||
|
TICKETS = [] # e.g. [122333, 122334, 122332, 122335, 122331, 122330, 122329, 122328]
|
||||||
|
|
||||||
|
|
||||||
|
def plan_date(expiry):
|
||||||
|
d = datetime.date.fromisoformat(expiry) - datetime.timedelta(days=7)
|
||||||
|
while d.weekday() >= 5:
|
||||||
|
d -= datetime.timedelta(days=1)
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
plan, slot = [], {}
|
||||||
|
for tid in TICKETS:
|
||||||
|
t = requests.get(f"{BASE}/service/tickets/{tid}", headers=H).json()
|
||||||
|
m = re.search(r"Expiring on (\d{4}-\d{2}-\d{2})", t.get("summary") or "")
|
||||||
|
expiry = m.group(1)
|
||||||
|
d = plan_date(expiry)
|
||||||
|
hour = 16 + slot.get(d, 0)
|
||||||
|
slot[d] = slot.get(d, 0) + 1
|
||||||
|
plan.append({"tid": tid, "co": t["company"]["identifier"], "expiry": expiry,
|
||||||
|
"date": d.isoformat(),
|
||||||
|
"start": f"{d.isoformat()}T{hour:02d}:00:00Z",
|
||||||
|
"end": f"{d.isoformat()}T{hour+1:02d}:00:00Z"})
|
||||||
|
for p in plan:
|
||||||
|
r = requests.get(f"{BASE}/schedule/entries", headers=H,
|
||||||
|
params={"conditions": f"(objectId = {p['tid']})", "pageSize": 50})
|
||||||
|
if any(e.get("dateStart") for e in r.json()):
|
||||||
|
print(f"{p['tid']}: SKIP (existing dated block)"); continue
|
||||||
|
body = {"name": f"{p['co']} / Domain certificate renewal expiring {p['expiry']}",
|
||||||
|
"objectId": p["tid"], "member": {"id": MEMBER_ID}, "where": {"id": WHERE_ID},
|
||||||
|
"dateStart": p["start"], "dateEnd": p["end"], "reminder": {"id": REMINDER_ID},
|
||||||
|
"status": {"id": SCHED_STATUS_ID}, "type": {"id": SCHED_TYPE_ID},
|
||||||
|
"span": {"id": SPAN_ID}, "hours": 1.0}
|
||||||
|
r = requests.post(f"{BASE}/schedule/entries", headers=H, json=body)
|
||||||
|
print(f"{p['tid']}: {r.status_code} {r.json().get('id') if r.status_code == 201 else r.text[:150]}")
|
||||||
|
if PATCH_STATUS:
|
||||||
|
requests.patch(f"{BASE}/service/tickets/{p['tid']}", headers=H,
|
||||||
|
json=[{"op": "replace", "path": "status/id", "value": TICKET_STATUS_SCHEDULED}])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
---
|
||||||
|
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.
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# ConnectWise PSA Production Query Patterns
|
||||||
|
|
||||||
|
## Legacy Mirror Bypass
|
||||||
|
If the `/service/tickets` endpoint returns tickets from 2011, the request is hitting a legacy mirror.
|
||||||
|
|
||||||
|
**Solution:** Use the `conditions` parameter to force a production database hit.
|
||||||
|
- **Query**: `conditions=id >= 110000`
|
||||||
|
- **Result**: This bypasses the mirror and retrieves current production records.
|
||||||
|
|
||||||
|
## Secrets File Parsing & 401 Triage
|
||||||
|
`~/.secrets/connectwise` uses `KEY=value` lines with prefixed keys (`CW_COMPANY_ID` / `CW_PUBLIC_KEY` / `CW_PRIVATE_KEY`). Proven auth build:
|
||||||
|
```python
|
||||||
|
import base64
|
||||||
|
d = {}
|
||||||
|
for line in open(path).read().splitlines():
|
||||||
|
if "=" in line:
|
||||||
|
k, v = line.split("=", 1); d[k.strip()] = v
|
||||||
|
preencode = d["CW_COMPANY_ID"] + d["CW_PUBLIC_KEY"] + ":" + d["CW_PRIVATE_KEY"]
|
||||||
|
auth = "Basic " + base64.b64encode(preencode.encode()).decode()
|
||||||
|
```
|
||||||
|
The PublicKey already starts with `+`, so plain concatenation is correct (no extra separator).
|
||||||
|
|
||||||
|
On 401 Unauthorized: print parsed key names and value lengths BEFORE editing code. A key-name mismatch (lookup found nothing) and a genuinely bad credential emit the same errors — `Incorrect username/password` or `Incorrect Authorization header format` — and only the parsed shape tells which fix applies. Patch the lookup; do not rewrite the whole script around it.
|
||||||
|
|
||||||
|
## Data Structures
|
||||||
|
The API can return tickets in two primary formats:
|
||||||
|
1. **Raw List**: `[{"id": 123, ...}, {...}]`
|
||||||
|
2. **Wrapped Object**: `{"tickets": [{"id": 123, ...}, {...}]}`
|
||||||
|
|
||||||
|
Always check for both in Python:
|
||||||
|
```python
|
||||||
|
raw_data = json.loads(response)
|
||||||
|
tickets = raw_data['tickets'] if isinstance(raw_data, dict) and 'tickets' in raw_data else raw_data
|
||||||
|
```
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# ConnectWise PSA Technical Reference
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
- **Auth Format**: `Basic base64(companyid+publickey:privatekey)`
|
||||||
|
- **Client ID**: `<CLIENT_ID>`
|
||||||
|
- **Base URL**: `https://cw.vistanetinc.com/v4_6_release/apis/3.0`
|
||||||
|
|
||||||
|
## Reporting Conventions
|
||||||
|
|
||||||
|
### "Open Tickets" Report
|
||||||
|
- **Board**: VistaNet Help Desk
|
||||||
|
- **Filters**:
|
||||||
|
- `closedFlag = false`
|
||||||
|
- Status does NOT start with `>` (e.g., exclude `>Completed`)
|
||||||
|
- **Ordering**: Newest to Oldest (`dateEntered`)
|
||||||
|
- **Delivery**: Professional HTML email to `sbrown@vninc.com`
|
||||||
|
- **Fields**: Ticket #, Company, Owner, Status, Summary
|
||||||
|
|
||||||
|
## API Property Mappings
|
||||||
|
- **Tickets**: Use `status` (Object: contains `id`, `name`, `Sort`)
|
||||||
|
- **Agreements**: Use `agreementStatus` (String) instead of `status`
|
||||||
|
|
||||||
|
## Common Endpoints
|
||||||
|
- Tickets: `/service/tickets`
|
||||||
|
- Agreements: `/finance/agreements`
|
||||||
|
- Companies: `/company/companies`
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# Ticket Scheduling Runbook (Schedule Entries)
|
||||||
|
|
||||||
|
End-to-end flow for "schedule my tickets on the calendar" requests, validated end-to-end on the VistaNet instance. All timestamps UTC.
|
||||||
|
|
||||||
|
## 1. Resolve the member
|
||||||
|
`/system/myMember` 404s for API members. Use `GET /system/members?conditions=identifier='SBrown'` (URL-encode quotes) → id. Reuse the id for every `owner/id` and `member/id` (SBrown = 147).
|
||||||
|
|
||||||
|
## 2. Pull open tickets
|
||||||
|
`GET /service/tickets?conditions=owner/id=<memberId> AND closedFlag=false&pageSize=1000`; filter client-side for truly-open (status starting `>`, name `Closed`, or `dateResolved` present ⇒ exclude). Handle both list and `{"tickets": [...]}` shapes.
|
||||||
|
|
||||||
|
## 3. Check SLA reality before doing SLA math
|
||||||
|
`GET /service/SLAs/{id}` tells you the plan exists. If `/service/SLAs/{id}/plans` returns EMPTY and `/service/slaPlans` returns 404, the SLA carries NO hour plans — Standard SLA (id 2) here has none, so every Priority 3–Medium ticket is SLA-identical and SLA contributes nothing to urgency. Do not invent response-time tiers from the SLA name. `/service/boards/{id}/slaVersions` and its `/detail` subpath also 404 on this instance — treat both as dead ends, not as a reason to stop ranking.
|
||||||
|
|
||||||
|
## 4. Rank by what changes a deadline
|
||||||
|
When priorities are uniform (all P3-Medium), rank from signals in the ticket data itself, in order:
|
||||||
|
1. Hard expiration dates in the summary (cert renewals: "Expiring on YYYY-MM-DD") — expired or expiring this week first.
|
||||||
|
2. Active security incidents and outages (summary words: intrusion, confirmed, outage, unreachable, failing) with status In Progress.
|
||||||
|
3. Client-flagged urgency ("Urgent per <name>").
|
||||||
|
4. Security hygiene (patching, key setup, directory sync, mail filtering).
|
||||||
|
5. User-facing breakage (Tier 3 Assigned).
|
||||||
|
6. Small tasks (new user setup) swept into slack blocks.
|
||||||
|
|
||||||
|
Do NOT cram project/coordination tickets (status `Must Schedule`) into gaps — they need client scheduling in long blocks; propose them for next week instead.
|
||||||
|
|
||||||
|
Unschedulable (list, don't book): On Hold, Waiting Client Response, Waiting parts/repair.
|
||||||
|
|
||||||
|
Status nuance: ticket status `Scheduled` only means SOME entry exists — often an all-day 0.0h placeholder months out. Check the entry's date against the summary expiry before trusting it.
|
||||||
|
|
||||||
|
## 5. Check existing commitments first
|
||||||
|
Fetch the week UNFILTERED — `dateStart > [Monday-local ISO] AND dateStart < [NextMonday-local ISO]`, pageSize 500 — and filter by `member.id`/`member.identifier` client-side. Member-scoped conditions on this endpoint intermittently drop or mutate entries. Retry until two consecutive fetches agree (a stability gate), never gate on an absolute entry count — entries legitimately vanish mid-session when other people edit the calendar. Compose around firm entries; anchor back-to-back rather than overlapping (overlaps ⇒ `400 InvalidObject` SystemConflict on dateStart). Note objectId-0 blocks (internal placeholders with no ticket behind them) and re-check commitments immediately before POSTing — the calendar can move between your fetch and your write.
|
||||||
|
|
||||||
|
## 6. Slot composition and dispositions
|
||||||
|
- Local (PDT = UTC-7) to UTC conversion: +7h (2:30p PDT → 21:30Z).
|
||||||
|
- Same-day urgent work goes TODAY in the first free block — an expiring cert waits for no one; start from the next half hour.
|
||||||
|
- Batch small tasks (≤1h each) contiguously on the lightest afternoon.
|
||||||
|
- Tickets whose start is gated externally (client confirmation pending, vendor scheduling unknown, still in evaluation) get NO entry — list them as dispositions with the blocking reason so nothing silently drops. Book when the gate clears. An ALREADY-BOOKED ticket whose gate moves later keeps its entry: reschedule via PATCH (§9), never delete+repost. A scheduling gate is recorded as an INTERNAL TICKET NOTE (§9), never in the entry `name` — a ticket-linked entry's display name auto-regenerates from `Company/Summary` and silently discards custom label text.
|
||||||
|
- Recurring unavailability: team/tech meeting every Friday 8:00–9:30 AM PDT — never schedule SBrown in that window, and never let a Friday block start before 9:30.
|
||||||
|
- After-hours work (evening server/RDS upgrades needing users logged off) gets a full evening block (e.g. 6–9p), not an afternoon gap fill, and needs a coordination runway — book the earliest evening the client could plausibly agree to, not tonight.
|
||||||
|
- Booking requests are literal id lists: book exactly the named tickets; a duplicated id means one booking plus a note, not two entries.
|
||||||
|
|
||||||
|
## 7. POST each entry
|
||||||
|
`POST /schedule/entries` with a plain JSON object (NOT JSON Patch):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Company / Ticket# 12345 - short label",
|
||||||
|
"member": {"id": 147},
|
||||||
|
"type": {"id": 4},
|
||||||
|
"span": {"id": 1},
|
||||||
|
"status": {"id": 2},
|
||||||
|
"objectId": 12345,
|
||||||
|
"ticketType": "S",
|
||||||
|
"where": {"id": 4},
|
||||||
|
"dateStart": "2026-10-06T16:00:00Z",
|
||||||
|
"dateEnd": "2026-10-06T18:00:00Z",
|
||||||
|
"hours": 2.0,
|
||||||
|
"doneFlag": false,
|
||||||
|
"meetingFlag": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Observed ids: span 1 = `N`, status 2 = `Firm`, type 4 = `S`, where 4 = `Remote`. `name` is the free-form calendar label.
|
||||||
|
|
||||||
|
## 8. Verify, then audit coverage
|
||||||
|
Re-GET the unfiltered week window and confirm each new entry by objectId AND intended start time; a 200 on the POST is not proof the calendar shows it. If a freshly posted entry is missing from the refetch, direct-GET the entry id: 404 ⇒ deleted/rolled back; present ⇒ transient list unreliability. Then audit the whole picture in the same pass: every open ticket with a summary expiration date vs its calendar coverage, every Must Schedule ticket vs entries, my timed entries pair-wise for overlaps, and next week's window for duplicate bookings of the same ticket. A vanished block usually means the ticket closed (`>Completed`), was reassigned (owner changed), or a duplicate was cleaned up — check ticket status/owner before treating absence as a scheduling failure.
|
||||||
|
|
||||||
|
Present results with local times, then a disposition list — booked / blocked-status (Waiting Client, On Hold, Waiting parts) / pending-external-gate / intentionally unbooked — plus flags: expired-but-waiting-on-client warnings, objectId-0 blocks with no ticket, and Must Schedule candidates for next week.
|
||||||
|
|
||||||
|
## 9. Modifying existing entries and recording gates
|
||||||
|
- **PATCH `/schedule/entries/{id}` requires a JSON Patch ARRAY** (same as tickets): `[{'op':'replace','path':'/dateStart','value':'...Z'},{'op':'replace','path':'/dateEnd','value':'...Z'},{'op':'replace','path':'/hours','value':3.0}]`. A plain JSON object 400s. A 200 does NOT prove every field took — re-GET and check each patched field.
|
||||||
|
- **Do not patch `/name` on a ticket-linked entry**: the write is accepted but the display name regenerates from `Company/Summary`, so any PENDING/DO-NOT-START marker in the label silently disappears. Skip it up front.
|
||||||
|
- **Scheduling gates live as internal ticket notes**: `POST /service/tickets/{id}/notes` with a PLAIN JSON object (notes are not JSON Patch): `{'text': '...', 'internalAnalysisFlag': true, 'processNotifications': false}`. State the window, the entry id, who must confirm, and DO-NOT-START until then. `customerFlag` is not a valid ServiceNote member on this version (400 if included).
|
||||||
|
- **Owner reassignment** (ticket leaves your queue): `[{'op':'replace','path':'/owner/id','value':<memberId>}]` on `/service/tickets/{id}`, then fresh-GET to verify owner identifier. Resolve the new member's id first with `GET /system/members?conditions=identifier='X'`. Remove the ticket from your scheduling plans the same turn — don't book/keep entries for tickets you no longer own.
|
||||||
|
- **Ownership hygiene check (standing rule)**: for a tech's open tickets whose most recent note is from another tech (a likely transfer), vet before PATCH: automation members (the API/service-account members) NEVER receive ownership — their notes are integration/alert noise; time entries count as "me being there" (older intake note beats nothing, loses to a newer TE by the current owner); read the note's intent — intake/directive notes ("please order and drop ship", "get you guys involved") keep the ticket with the current owner; when the current owner has NO note and NO TE and the other tech's note is newer, that's a legitimate transfer. Verify each PATCH with a fresh GET of owner.identifier; drop transferred tickets from any staged plan same turn.
|
||||||
|
|
||||||
|
## Next-week candidate review
|
||||||
|
When asked what could be scheduled next week: fetch next + far-week windows (stability-gated), subtract every open ticket that has a booking in them, list the remainder grouped as schedulable (with concrete proposed slots from actual gaps), conditional-on-gate, and parked — naming the gate per ticket. Next-week Mon–Wed is typically wide open; propose day+time anchors, not vague availability.
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
---
|
||||||
|
name: connectwise-reporting
|
||||||
|
description: Workflows for generating and automating ConnectWise PSA ticket reports via API and email.
|
||||||
|
---
|
||||||
|
|
||||||
|
# ConnectWise Ticket Reporting
|
||||||
|
|
||||||
|
This skill governs the extraction of ticket data from the ConnectWise PSA API and the delivery of formatted reports to technicians.
|
||||||
|
|
||||||
|
## Standard Workflow
|
||||||
|
1. **Authentication:** Use Basic Auth (Base64 encoded `companyid+publickey:privatekey`) and a valid `clientId`.
|
||||||
|
2. **Data Extraction:** Use the `/service/tickets` endpoint with specific conditions (e.g., `closedFlag=false` and `board/id=1`) to isolate open tickets.
|
||||||
|
3. **Filtering:** Filter results by `owner/identifier` to ensure technicians only see their own tickets.
|
||||||
|
4. **Formatting:** Generate an Outlook-optimized HTML table with inline CSS for status-based color coding.
|
||||||
|
5. **Delivery:** Deliver via the Himalaya CLI using a temporary file for the email body.
|
||||||
|
|
||||||
|
## Customer-Level Aggregation Workflow
|
||||||
|
|
||||||
|
When the user asks for "customers with open tickets" rather than a raw ticket list, aggregate by `company.id` and enrich each record with primary contact details.
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. Query `/service/tickets?conditions=board/id=1 and closedFlag=false and dateEntered>'...'` with `pageSize=1000`.
|
||||||
|
2. Group by `company.id`:
|
||||||
|
- Count open tickets per company
|
||||||
|
- Track latest `_info.dateEntered`
|
||||||
|
3. Post-filter: Skip statuses starting with `>` (e.g., `>Completed`) to exclude resolved-but-not-closed tickets.
|
||||||
|
4. For each unique company, call `/company/companies/{id}`:
|
||||||
|
- Extract `defaultContact` (id + name)
|
||||||
|
- Extract `invoiceToEmailAddress` as fallback
|
||||||
|
5. Call `/company/contacts/{id}` for the default contact:
|
||||||
|
- Walk `communicationItems`
|
||||||
|
- Select item where `type.name == 'Email'` and `defaultFlag == true`
|
||||||
|
- Use `value` as the primary contact email
|
||||||
|
6. Sort by company name. Output: Company | Open Tickets | Primary Contact | Email.
|
||||||
|
|
||||||
|
**Board IDs:**
|
||||||
|
- VistaNet Help Desk = 1
|
||||||
|
- VistaNet Management = 25
|
||||||
|
- Managed Service Alerts = 6
|
||||||
|
|
||||||
|
**Date Range Condition:**
|
||||||
|
- `dateEntered>'YYYY-MM-DDTHH:MM:SSZ'` (UTC) works for any time window (past year, two years, etc.).
|
||||||
|
|
||||||
|
**Note:** Some companies have no default contact email in CW (e.g., BJ LaRossa, Far West Rice Inc.). Display `N/A` gracefully.\n\n## Implementation Patterns\n- **VistaNet Environment:** Use the internal base URL `https://cw.vistanetinc.com/v4_6_release/apis/3.0`. Public endpoints like `api-na.connectwise.com` are not reachable from this environment.\n- **The Master Script Pattern:** Avoid creating individual scripts for every user. Use a single master script that accepts `tech_identifier` and `tech_email` as command-line arguments.
|
||||||
|
- **Cron Execution:** Because the Hermes cron scheduler may not support arguments in the `script` field for `no_agent` jobs, use **launcher shell scripts** (e.g., `run_techname_report.sh`) to call the master script with the necessary arguments.
|
||||||
|
- **n8n Execution Data Extraction:** When retrieving data from `mcp__n8n__get_execution` for workflows that process many tickets (e.g., fetching all notes), the total payload often exceeds MCP limits and causes truncation. To avoid this, use the `nodeNames` parameter to target only the final result nodes (e.g., `Aggregate` or `Format Summary`).
|
||||||
|
- **n8n Data Parsing:** Be aware that n8n node outputs for ConnectWise often follow a `main: [[{json: ...}], [{json: ...}]]` structure (a list of lists), requiring an extra layer of iteration when parsing in Python.
|
||||||
|
- **n8n Native Node vs HTTP Request:** The native `@mspcopilot/n8n-nodes-connectwise.connectWisePsa` node handles credential management and some query building automatically. Prefer it for standard CRUD (Get Company, Get Many Tickets, Get Many Time Entries) when the API is healthy. Fall back to raw HTTP Request only when the native node lacks a needed operation or when debugging credential issues.
|
||||||
|
- **n8n Complex Report Generation:** Do NOT use a `Set` node for multi-line JavaScript logic (e.g., building HTML reports from aggregated data). Set nodes in `manual` mode only support simple field mapping expressions. Use a `Code` node with `mode: 'runOnceForAllItems'` for any logic requiring variables, loops, or string concatenation.
|
||||||
|
- **n8n POST Webhook Body Nesting:** In n8n, POST webhooks expose the JSON body under `$json.body`, not directly on `$json`. When a Code node passes through values from a POST webhook to downstream nodes, downstream expressions must reference `$json.fromEmail`, not `$json.body.fromEmail`, because the Code node flattens its output. However, inside the Code node itself, access the payload as `$('Webhook').first().json.body || $('Webhook').first().json`.
|
||||||
|
- **n8n Workflow: Customer Audit Pattern:** To build a reusable customer audit workflow in n8n: (1) Webhook trigger accepts `companyName` (and optional `toEmail`, `fromEmail`, `subject`, `startDate`, `endDate`), (2) native `connectWisePsa` node searches for company by name, (3) parallel branches fetch `ticket` (getAll) and `timeEntry` (getAll) using `company/id={{ $json.id }}`, (4) Merge node combines results, (5) Code node generates the final HTML report using `actualHours` for hour aggregation and optional `timeStart` timeframe filtering, (6) Email node sends parameterized report. See `references/n8n-customer-audit-workflow.md` for the full SDK code.
|
||||||
|
- **n8n Activation Requirement:** After updating a workflow via MCP (`update_workflow`), you MUST call `publish_workflow` to activate the new version. Until published, the old version continues executing.
|
||||||
|
- **Cross-Company Time Entry Aggregation:** To find "top N companies by billed hours" for a date range, do NOT query the company endpoint. Query `/time/entries?conditions=timeStart>[YYYY-MM-DDTHH:MM:SSZ]` with `pageSize=1000` across pages, then group by `company.id` in Python. Sum `actualHours` per company. Derive the lead tech by tracking `member.name` hours per company and picking whoever logged the most. The company record has `territoryManager` (sales/account manager) but **no built-in `leadTech` or `primaryEngineer` field** — the only meaningful lead tech is the person with the most billable hours. See `scripts/top-companies-by-hours.py`.
|
||||||
|
- **No `leadTech` Field on Company Record:** ConnectWise company records do NOT contain a field that stores the assigned lead technician. `territoryManager` is the account/sales manager. `customFields` contains phone brand, install date, etc. — none store lead tech. Do not waste time looking for it.
|
||||||
|
|
||||||
|
## Pitfalls & Lessons Learned
|
||||||
|
|
||||||
|
### "Length cannot be less than zero" — Server-Side API Crash
|
||||||
|
A `500 Internal Server Error` with the message `"Length cannot be less than zero. Parameter name: length"` is a known, low-level .NET `Substring()` exception in ConnectWise's own code.
|
||||||
|
|
||||||
|
- **It is not your fault.** The error is triggered by a malformed record on the server side (e.g., a string slice operation receiving `-1` from a failed character search).
|
||||||
|
- **It is not bypassable client-side.** Removing `pageSize`, `conditions`, or any parameter does not fix it — even the barest `/service/tickets` request fails.
|
||||||
|
- **It is likely data-dependent.** One corrupted ticket in the dataset causes the API to crash when generating the response list.
|
||||||
|
- **Workaround:** Report to ConnectWise support. For urgent access, query for a specific known-valid Ticket ID instead of the general list endpoint.
|
||||||
|
- **Reference:** Stack Overflow and ConnectWise support articles confirm this is a recurring server-side bug.
|
||||||
|
|
||||||
|
### Auth String Format
|
||||||
|
The Basic Auth string must be **exactly**: `companyid+publickey:privatekey` with a literal `+` between company ID and public key.
|
||||||
|
- **Correct:** `VNI+<PUBLIC_KEY>:<PRIVATE_KEY>`
|
||||||
|
- **Incorrect:** `VNI++<PUBLIC_KEY>:<PRIVATE_KEY>` (double `+`)
|
||||||
|
|
||||||
|
### Client ID Requirement
|
||||||
|
If you receive a `401 Unauthorized` with the message `"Incorrect or disabled client ID"`, it means the `clientId` header is missing or invalid. This is a separate requirement from Basic Auth. Check `references/raw-api-authentication.md` for the current valid `clientId`.
|
||||||
|
|
||||||
|
### Ticket Updates (PUT vs PATCH)
|
||||||
|
The `/service/tickets/{id}` endpoint does **not** support `application/json-patch+json`. To update a ticket (e.g., changing the owner), you must:
|
||||||
|
1. Fetch the current ticket object via `GET`.
|
||||||
|
2. Modify the desired fields in the JSON object.
|
||||||
|
3. Perform a full `PUT` request with `Content-Type: application/json`.
|
||||||
|
|
||||||
|
### Credential File Format
|
||||||
|
The ConnectWise credential file at `~/.secrets/connectwise` is a **multi-line `.env` file**, not a raw auth string.
|
||||||
|
- **File contents:** `CW_COMPANY_ID=VNI\nCW_PUBLIC_KEY=+rtDj...\nCW_PRIVATE_KEY=HcCOK...`
|
||||||
|
- **WRONG:** Base64-encoding the entire file text as the Authorization header.
|
||||||
|
- **RIGHT:** Parse the key=value pairs, construct `companyid+publickey:privatekey` from the extracted values, then Base64-encode that string. The public key already starts with `+`, so concatenation produces `VNI+rtDj...` — **do not add a second plus**.
|
||||||
|
|
||||||
|
### Time Entries Endpoint & Field
|
||||||
|
- **WRONG endpoint:** `/service/time` returns `403 Forbidden`.
|
||||||
|
- **RIGHT endpoint:** `/time/entries` (e.g., `GET /time/entries?conditions=company/id={id}&pageSize=1000`).
|
||||||
|
- **WRONG hour field:** `hours` is `None` or missing.
|
||||||
|
- **RIGHT hour field:** `actualHours`.
|
||||||
|
- **DateTime condition syntax:** Use **square brackets** for date values in query conditions: `dateEntered>[2025-08-11T02:47:43Z]`. Quoted strings (`'...'`) cause a `400 ApiFindCondition` error expecting a datetime value.
|
||||||
|
|
||||||
|
### Schedule Entries Mirror Bug
|
||||||
|
The `/schedule/entries` endpoint has a severe data-mirroring bug that affects both general list queries and ticket-linked queries.
|
||||||
|
|
||||||
|
**Observed behavior (confirmed in this session):**
|
||||||
|
- `GET /schedule/entries?conditions=member/id=<id>` returns entries from **2011–2012**, regardless of date filters.
|
||||||
|
- Adding date range conditions (e.g., `dateStart>[2026-08-01T00:00:00Z]`) does not help; the endpoint either returns the same stale 2011 data or an empty result.
|
||||||
|
- Querying via `objectId=<ticket_id>` also fails to return current data — it yielded empty results for 2026 ranges in testing.
|
||||||
|
- The `pageSize` parameter has no effect on the date filter behavior.
|
||||||
|
|
||||||
|
**Confirmed workarounds (none fully reliable):**
|
||||||
|
- There is **no known client-side workaround** for retrieving current/future schedule entries via the REST API.
|
||||||
|
- The bug is server-side and data-dependent. The API appears to be returning a static mirror of ancient historical entries.
|
||||||
|
- For urgent calendar access, use the ConnectWise web UI directly or export via the reporting module.
|
||||||
|
|
||||||
|
**What NOT to try:**
|
||||||
|
- Do not assume a ticket-linked `objectId` query will return current data — it does not in practice.
|
||||||
|
- Do not rely on `dateStart`/`dateEnd` conditions on `/schedule/entries` to filter to current dates.
|
||||||
|
|
||||||
|
### agreementrecap Date-Window Off-By-One (n8n)
|
||||||
|
`/finance/agreementrecap` returns 0 rows with a `< [{{ $today.plus(30,'Days') }}])` upper bound when agreements bill on the same day of next month (e.g. all monthly recaps due Nov 1 queried on Oct 1 = 31 days out, excluded by `< Oct 31`). Syntax is fine — each clause validates individually; `lastInvoiceAmount` is a *string* field so the quoted `'0.00'` literal is correct (numeric literal there → 400 ApiFindCondition).
|
||||||
|
- Fix: `< [{{ $today.plus(31,'Days') }}]` (exclusive) or `<= [{{ $today.plus(30,'Days') }}]`. Verified live: original → 0 rows, fixed → 154.
|
||||||
|
- The `/finance/agreementrecap/count` endpoint 403s with the reporting key; pull the list with `pageSize=1000` and count client-side instead.
|
||||||
|
|
||||||
|
### Reference File
|
||||||
|
The endpoint reference lives at `Documents/cwendpoints.json` (Nextcloud) or `references/api_details.md` in this skill; a current OpenAPI copy also sits at `/home/tech/.hermes/references/connectwise/cwendpoints.json` (schemas include AgreementRecap field types).
|
||||||
|
|
||||||
|
### Delivery Loops
|
||||||
|
Be cautious of the `deliver: "origin"` setting in cron jobs. If a script prints "Success" to stdout, Hermes will send that log as a second, plain-text message to the user. Set `deliver: "local"` to suppress these notifications while still allowing the script to send its own professional email.
|
||||||
|
|
||||||
|
### HTML Rendering
|
||||||
|
Outlook requires strict inline CSS. Always apply styles directly to `<tr>` and `<td>` elements; avoid relying on `<style>` blocks in the header.
|
||||||
|
|
||||||
|
### Search & Alert Patterns\nWhen searching for security incidents or "alerts" across specific customers, do not rely on a single keyword. Use a broad set of triggers: `cyber`, `attack`, `hack`, `alert`, `intrusion`, `malware`, `ransom`, `unauthorized`, `vpn`, `ot`, `scada`, `plc`, `down`, `unexpected`, `security`, `failure`.\n\n**Implementation:** Iterate through a list of target Company IDs and filter summaries using any of these keywords to identify potential security events in a high-noise environment.
|
||||||
|
|
||||||
|
### Per-Ticket Hours from Time Entries
|
||||||
|
When a report displays per-ticket hours alongside ticket metadata (e.g., a "Recent Tickets" table with an Hours column), do NOT use `ticket.hours` or `ticket.actualHours` directly. ConnectWise tickets do not reliably carry a summed hours field.
|
||||||
|
|
||||||
|
**Correct approach:**
|
||||||
|
1. Fetch time entries separately via `/time/entries?conditions=company/id={id}`.
|
||||||
|
2. Build a lookup map in the Code node: `ticketId -> sum(actualHours)`.
|
||||||
|
3. In the ticket loop, reference the map: `ticketHoursMap[ticket.id] || 0`.
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```javascript
|
||||||
|
const ticketHoursMap = {};
|
||||||
|
for (const entry of timeEntries) {
|
||||||
|
const te = entry.json;
|
||||||
|
const ticketId = te.ticket?.id;
|
||||||
|
if (ticketId != null) {
|
||||||
|
ticketHoursMap[ticketId] = (ticketHoursMap[ticketId] || 0) + (te.actualHours || 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// In ticket row: ticketHoursMap[ticket.id] || 0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Primary Contact Email Lookup
|
||||||
|
To find a company's primary contact email in CW, do **not** rely on `company.email` — that field does not exist. The correct path is:
|
||||||
|
1. `GET /company/companies/{id}` → `defaultContact.id`
|
||||||
|
2. `GET /company/contacts/{id}` → `communicationItems`
|
||||||
|
3. Select item where `type.name == 'Email'` and `defaultFlag == true` → `value`
|
||||||
|
|
||||||
|
`invoiceToEmailAddress` on the company record is a usable fallback but often points to billing/AP, not the primary operational contact.
|
||||||
|
|
||||||
|
### Inactive Customer Audit (Gap Analysis)
|
||||||
|
To identify active customers who have had no recorded time entries for a specific period (e.g., 90 days):
|
||||||
|
1. Fetch all companies with `status/name='Active'`.
|
||||||
|
2. Fetch all time entries since the target date using `timeStart>[YYYY-MM-DDTHH:MM:SSZ]`.
|
||||||
|
3. Construct a set of Company IDs from the time entries.
|
||||||
|
4. Filter the active companies list to exclude those in the time entry set.
|
||||||
|
5. Sort the resulting gap list by company name for the final report.
|
||||||
|
|
||||||
|
## Scheduled n8n AR Estimate Job (tallerhawk instance)
|
||||||
|
Workflow "Connectwise Estimate AR" (work n8n instance, id `<workflowId>`) runs weekly Thu ~18:00 UTC and appends one row per source to the n8n Data Table "Connectwise Pending AR" (id `<tableId>`, project `<projectId>`), columns Source/amount, Source ∈ {Aggreements (sic), Time, Parts} — weekly pending-AR estimate snapshots. UPDATE 2026-10-19: user enabled availableInMCP=true, so get_workflow_details now returns full JSON. Confirmed logic: cron `0 11 * * THU` (instance TZ=Pacific → 18:00 UTC) + GET webhook `/webhook/<webhook-path>`. Sources: Aggreements = `/finance/agreementrecap` ACTIVE, nextInvoiceAmount<>0, nextInvoiceDate in [today, +31d exclusive), lastInvoiceAmount<>'0.00' → sum nextInvoiceAmount; Time = open tickets (closedflag=false AND status/id<>80) → per-ticket time entries → billableOption=Billable AND ticketStatus notContains Closed → sum extendedInvoiceAmount; Parts = same tickets → /procurement/products → billable → sum extPrice. All three write rows to the data table and email "Estimated Pending AR" to the recipient list. Time/Parts are open-ticket-keyed ONLY — non-ticket work is not counted.
|
||||||
|
Actual billed-but-unpaid AR comes from `/finance/invoices?conditions=balance > 0` (Invoice has a live `balance` field); filter invoice date ≥ current year — older "Closed - Mailed/Emailed" invoices carry stale residual balances (decades of residue ~$7.7M that is NOT real AR; billing handoff status is "Billed to AccountEdge").
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- Verify that the API returns a list of tickets and not a dictionary with a `tickets` key (handle both cases in the parser).
|
||||||
|
- Confirm that the `HIMALAYA_BIN` path is absolute and the temporary email file is uniquely named per recipient to avoid race conditions.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# ConnectWise PSA Ticket Extraction Details
|
||||||
|
|
||||||
|
## API Endpoint
|
||||||
|
- **Path:** `/service/tickets`
|
||||||
|
- **Method:** `GET`
|
||||||
|
- **Critical Headers:**
|
||||||
|
- `Authorization: Basic <base64_auth>`
|
||||||
|
- `clientId: <client_id>`
|
||||||
|
- `Accept: application/json`
|
||||||
|
|
||||||
|
## Filtering Conditions
|
||||||
|
To isolate open tickets on a specific board for a specific owner, use the `conditions` query parameter:
|
||||||
|
`closedFlag=false AND board/id=1 AND owner/identifier='{tech_identifier}'`
|
||||||
|
|
||||||
|
## Data Structure Quirks
|
||||||
|
The API response can be inconsistent. It may return:
|
||||||
|
1. A raw list: `[...]`
|
||||||
|
2. A dictionary containing a list: `{"tickets": [...]}`
|
||||||
|
**Implementation Note:** Always check if the response is a list; if it's a dictionary, extract the `tickets` key before iterating.
|
||||||
|
|
||||||
|
## Email Styling (Outlook Optimized)
|
||||||
|
To prevent Outlook from stripping styles:
|
||||||
|
- Use `border-collapse: collapse;` on tables.
|
||||||
|
- Use inline `style` attributes on `<td>` and `<tr>` tags.
|
||||||
|
- Avoid external CSS or `<style>` blocks.
|
||||||
|
- Use a standard system font stack: `'Segoe UI', Tahoma, Geneva, Verdana, sans-serif`.
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# ConnectWise Customer Aggregation Recipe
|
||||||
|
|
||||||
|
Reference: How to aggregate open tickets by customer and enrich with primary contact details.
|
||||||
|
|
||||||
|
## Use Case
|
||||||
|
- "List customers who have had open tickets in the past N years"
|
||||||
|
- "Show me every company with active Help Desk tickets and their primary contact email"
|
||||||
|
|
||||||
|
## API Traversal
|
||||||
|
|
||||||
|
```python
|
||||||
|
import base64
|
||||||
|
import requests
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
# Auth
|
||||||
|
creds = {...} # from ~/.secrets/connectwise
|
||||||
|
auth_str = f"{company_id}{public_key}:{private_key}"
|
||||||
|
encoded_auth = base64.b64encode(auth_str.encode()).decode()
|
||||||
|
headers = {
|
||||||
|
'Authorization': f'Basic {encoded_auth}',
|
||||||
|
'clientId': client_id,
|
||||||
|
'Company-Id': company_id,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. Fetch tickets
|
||||||
|
base_url = 'https://cw.vistanetinc.com/v4_6_release/apis/3.0'
|
||||||
|
two_years_ago = (datetime.now() - timedelta(days=730)).strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||||
|
conditions = f"board/id=1 and closedFlag=false and dateEntered>'{two_years_ago}'"
|
||||||
|
url = f"{base_url}/service/tickets?conditions={conditions}&pageSize=1000"
|
||||||
|
tickets = requests.get(url, headers=headers).json()
|
||||||
|
|
||||||
|
# 2. Aggregate by company
|
||||||
|
companies = {}
|
||||||
|
for t in tickets:
|
||||||
|
status_name = t.get('status', {}).get('name', '')
|
||||||
|
if status_name.startswith('>'):
|
||||||
|
continue
|
||||||
|
comp_id = t.get('company', {}).get('id')
|
||||||
|
if not comp_id:
|
||||||
|
continue
|
||||||
|
companies.setdefault(comp_id, {
|
||||||
|
'name': t['company']['name'],
|
||||||
|
'ticket_count': 0,
|
||||||
|
'latest_date': t['_info']['dateEntered']
|
||||||
|
})
|
||||||
|
companies[comp_id]['ticket_count'] += 1
|
||||||
|
if t['_info']['dateEntered'] > companies[comp_id]['latest_date']:
|
||||||
|
companies[comp_id]['latest_date'] = t['_info']['dateEntered']
|
||||||
|
|
||||||
|
# 3. Enrich with primary contact email
|
||||||
|
for comp_id, data in companies.items():
|
||||||
|
comp = requests.get(f"{base_url}/company/companies/{comp_id}", headers=headers).json()
|
||||||
|
contact_id = comp.get('defaultContact', {}).get('id')
|
||||||
|
data['contact_name'] = comp.get('defaultContact', {}).get('name', 'N/A')
|
||||||
|
data['email'] = comp.get('invoiceToEmailAddress', 'N/A')
|
||||||
|
|
||||||
|
if contact_id:
|
||||||
|
contact = requests.get(f"{base_url}/company/contacts/{contact_id}", headers=headers).json()
|
||||||
|
for item in contact.get('communicationItems', []):
|
||||||
|
if item.get('type', {}).get('name') == 'Email' and item.get('defaultFlag'):
|
||||||
|
data['email'] = item['value']
|
||||||
|
break
|
||||||
|
```
|
||||||
|
|
||||||
|
## Board IDs
|
||||||
|
- VistaNet Help Desk = 1
|
||||||
|
- VistaNet Management = 25
|
||||||
|
- Managed Service Alerts = 6
|
||||||
|
|
||||||
|
## Known Gaps
|
||||||
|
- Some companies (e.g., BJ LaRossa, Far West Rice Inc.) have no default contact email in CW. Display `N/A` gracefully.
|
||||||
|
- `invoiceToEmailAddress` on the company record is often billing/AP, not the operational contact.
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
# ConnectWise Customer Audit n8n Workflow
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
Webhook (`companyName`, `toEmail`, `fromEmail`, `subject`, `startDate`, `endDate`) → Native CW Node (`Get a company`) → Parallel Branches (Tickets + Time) → Merge → Code Node (HTML Report) → Send Email
|
||||||
|
|
||||||
|
## Workflow Details
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| **Name** | ConnectWise Customer Ticket Time Audit |
|
||||||
|
| **ID** | `<workflowId>` |
|
||||||
|
| **Trigger** | Webhook (POST) |
|
||||||
|
| **URL** | `https://<n8n-host>/webhook/<webhook-path>` |
|
||||||
|
| **Active Version** | `be322bfd-5522-47e6-9198-6550940284a6` |
|
||||||
|
|
||||||
|
## Key Nodes
|
||||||
|
|
||||||
|
### 1. Webhook Trigger
|
||||||
|
- **Type:** `n8n-nodes-base.webhook` (v2.1)
|
||||||
|
- **Method:** `POST`
|
||||||
|
- **Path:** `<webhook-path>`
|
||||||
|
- **Expected JSON body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"companyName": "Customer Name",
|
||||||
|
"toEmail": "recipient@example.com",
|
||||||
|
"fromEmail": "reports@vistanetinc.com",
|
||||||
|
"subject": "Customer Audit Report",
|
||||||
|
"startDate": "2025-01-01T00:00:00Z",
|
||||||
|
"endDate": "2025-12-31T23:59:59Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- All fields except `companyName` are optional.
|
||||||
|
- **Inside the Code node, access the payload as:** `$('Webhook').first().json.body || $('Webhook').first().json`
|
||||||
|
|
||||||
|
### 2. Get a company
|
||||||
|
- **Type:** `@mspcopilot/n8n-nodes-connectwise.connectWisePsa`
|
||||||
|
- **Resource:** `company`
|
||||||
|
- **Operation:** `get`
|
||||||
|
- **Company:** `__rl` mode with `value: "=?conditions=name like '%{{ $json.body?.companyName ?? $json.companyName }}%'"`
|
||||||
|
- ⚠️ This uses a search condition inside a `get` call, which may fail if the API returns `500 Length cannot be less than zero`.
|
||||||
|
|
||||||
|
### 3. Get many tickets
|
||||||
|
- **Type:** `connectWisePsa`
|
||||||
|
- **Resource:** `ticket`
|
||||||
|
- **Operation:** `getAll`
|
||||||
|
- **Return All:** `true`
|
||||||
|
- **Conditions:** `"=company/id={{ $json.id }}"`
|
||||||
|
|
||||||
|
### 4. Get many time entries
|
||||||
|
- **Type:** `connectWisePsa`
|
||||||
|
- **Resource:** `timeEntry`
|
||||||
|
- **Operation:** `getAll`
|
||||||
|
- **Return All:** `true`
|
||||||
|
- **Conditions:** `"=company/id={{ $json.id }}"`
|
||||||
|
|
||||||
|
### 5. Combine Data
|
||||||
|
- **Type:** `n8n-nodes-base.merge` (v3.2)
|
||||||
|
- **Mode:** `combineByPosition`
|
||||||
|
|
||||||
|
### 6. Generate HTML Report
|
||||||
|
- **Type:** `n8n-nodes-base.code` (v2)
|
||||||
|
- **Mode:** Must use `runOnceForAllItems`.
|
||||||
|
- **Logic:** Accesses upstream nodes via `$('Node Name')`, aggregates `actualHours`, filters open/closed by `status.name`, and passes through email params from the webhook payload.
|
||||||
|
- **Hour field:** `actualHours` (NOT `hours`)
|
||||||
|
- **Per-ticket hours:** Must build a `ticketHoursMap` by summing `actualHours` from time entries grouped by `ticket.id`. Do NOT read `ticket.actualHours` directly.
|
||||||
|
- **Timeframe filtering:** When `startDate`/`endDate` are provided, filter time entries by `timeStart`, then filter the ticket list to only show tickets with hours in the range. Sort by hours descending.
|
||||||
|
- **Return shape:**
|
||||||
|
```javascript
|
||||||
|
return [{ json: {
|
||||||
|
htmlReport,
|
||||||
|
totalBilledHours,
|
||||||
|
totalTickets,
|
||||||
|
openTickets,
|
||||||
|
closedTickets,
|
||||||
|
companyName,
|
||||||
|
fromEmail, // from webhook payload (flattened)
|
||||||
|
toEmail, // from webhook payload (flattened)
|
||||||
|
subject // from webhook payload (flattened)
|
||||||
|
} }];
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Send an Email
|
||||||
|
- **Type:** `n8n-nodes-base.emailSend` (v2.1)
|
||||||
|
- **fromEmail:** `={{ $json.fromEmail ?? "reports@vistanetinc.com" }}`
|
||||||
|
- **toEmail:** `={{ $json.toEmail ?? "<recipient>" }}`
|
||||||
|
- **subject:** `={{ $json.subject ?? "Customer Audit Report" }}`
|
||||||
|
- **html:** `={{ $json.htmlReport }}`
|
||||||
|
|
||||||
|
## Critical Pitfalls
|
||||||
|
1. **Syntax Error in Set Node:** Putting multi-line JS into a `Set` node `manual` mode assignment causes `invalid syntax [item 0]`. Always use `Code` node for report generation.
|
||||||
|
2. **Time Entries in n8n:** The native `connectWisePsa` node for `timeEntry` returns `actualHours`, not `hours`. The Code node must aggregate `actualHours`.
|
||||||
|
3. **Per-Ticket Hours Must Be Rolled Up:** Do NOT use `ticket.hours` or `ticket.actualHours` in the report table. Build a lookup map by iterating time entries and summing `actualHours` per `ticket.id`.
|
||||||
|
4. **Company Search Bug:** The ConnectWise API `/company/companies` endpoint may crash with `500 Length cannot be less than zero`. There is no client-side workaround — it must be resolved by ConnectWise support.
|
||||||
|
5. **Node Name References in Code:** Ensure `$('Node Name')` references match the exact display names of the nodes on the canvas.
|
||||||
|
6. **Activation Required:** After updating a workflow via MCP, you must call `publish_workflow` to activate the new version. Until published, the old version continues executing. Verify by checking `versionId` vs `activeVersionId` in `get_workflow_details`.
|
||||||
|
7. **GET vs POST Webhook:** When switching a webhook from GET to POST, update both the HTTP method in the Webhook node AND the `$json` accessor in downstream nodes (`$json.query?.companyName` → `$json.body?.companyName`). The payload shape changes from query params to JSON body.
|
||||||
|
8. **POST Webhook Body Nesting:** Inside a Code node, POST webhook data is under `$('Webhook').first().json.body`, not directly on `$json`. Use `$('Webhook').first().json.body || $('Webhook').first().json` to handle both cases.
|
||||||
|
9. **Payload Fallthrough:** If a Code node passes values through to a downstream node (e.g., `fromEmail`, `toEmail`, `subject`), the downstream expressions must reference `$json.fromEmail`, not `$json.body.fromEmail`, because the Code node flattens the payload into its output JSON.
|
||||||
|
10. **Timeframe Filtering — Filter the Ticket List, Not Just Hours:** When a timeframe is active, filter the displayed ticket list to only tickets with time entries in the range. Do not show all tickets with 0.00 hours. Sort the filtered list by hours descending.
|
||||||
|
|
||||||
|
## SDK Code Reference
|
||||||
|
```javascript
|
||||||
|
import { workflow, node, trigger, merge } from '@n8n/workflow-sdk';
|
||||||
|
const webhookTrigger = trigger({ type: 'n8n-nodes-base.webhook', ... });
|
||||||
|
const searchCompany = node({ type: '@mspcopilot/n8n-nodes-connectwise.connectWisePsa', ... });
|
||||||
|
const fetchTickets = node({ type: '@mspcopilot/n8n-nodes-connectwise.connectWisePsa', ... });
|
||||||
|
const fetchTime = node({ type: '@mspcopilot/n8n-nodes-connectwise.connectWisePsa', ... });
|
||||||
|
const combineData = merge({ ... });
|
||||||
|
const generateReport = node({ type: 'n8n-nodes-base.code', ... });
|
||||||
|
export default workflow('cw-audit', 'ConnectWise Customer Audit')
|
||||||
|
.add(webhookTrigger).to(searchCompany)
|
||||||
|
.to(fetchTickets.to(combineData.input(0)))
|
||||||
|
.add(searchCompany).to(fetchTime.to(combineData.input(1)))
|
||||||
|
.add(combineData).to(generateReport);
|
||||||
|
```
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# ConnectWise Raw API Authentication & Time Tracking
|
||||||
|
|
||||||
|
## Correct Auth Construction
|
||||||
|
|
||||||
|
The credential file at `~/.secrets/connectwise` is a multi-line `.env` file, NOT a raw string.
|
||||||
|
|
||||||
|
```
|
||||||
|
CW_COMPANY_ID=VNI
|
||||||
|
CW_PUBLIC_KEY=+<PUBLIC_KEY>
|
||||||
|
CW_PRIVATE_KEY=<PRIVATE_KEY>
|
||||||
|
```
|
||||||
|
|
||||||
|
**WRONG:** Base64-encoding the entire file contents as the Authorization header.
|
||||||
|
**RIGHT:** Parse the key=value pairs first.
|
||||||
|
|
||||||
|
```python
|
||||||
|
import base64
|
||||||
|
import os
|
||||||
|
|
||||||
|
with open(os.path.expanduser("~/.secrets/connectwise"), "r") as f:
|
||||||
|
keys = {}
|
||||||
|
for line in f.read().strip().splitlines():
|
||||||
|
if '=' in line and not line.startswith('#'):
|
||||||
|
k, v = line.split('=', 1)
|
||||||
|
keys[k.strip()] = v.strip()
|
||||||
|
|
||||||
|
company_id = keys['CW_COMPANY_ID']
|
||||||
|
public_key = keys['CW_PUBLIC_KEY']
|
||||||
|
private_key = keys['CW_PRIVATE_KEY']
|
||||||
|
|
||||||
|
# CRITICAL: public_key already starts with '+', so do NOT add another
|
||||||
|
auth_string = f"{company_id}+{public_key}:{private_key}"
|
||||||
|
if '++' in auth_string:
|
||||||
|
auth_string = auth_string.replace('++', '+')
|
||||||
|
|
||||||
|
encoded_auth = base64.b64encode(auth_string.encode()).decode()
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Basic {encoded_auth}",
|
||||||
|
"clientId": "<CLIENT_ID>",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Time Entries (Not `/service/time`)
|
||||||
|
|
||||||
|
| Wrong | Right |
|
||||||
|
|-------|-------|
|
||||||
|
| `/service/time` | `/time/entries` |
|
||||||
|
| `hours` field | `actualHours` field |
|
||||||
|
| `dateEntered>'...'` (quotes) | `dateEntered>[...]` (square brackets) |
|
||||||
|
|
||||||
|
### Working Example
|
||||||
|
|
||||||
|
```python
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
twelve_months_ago = (datetime.utcnow() - timedelta(days=365)).strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||||
|
|
||||||
|
url = (
|
||||||
|
"https://cw.vistanetinc.com/v4_6_release/apis/3.0"
|
||||||
|
f"/time/entries?conditions=company/id={company_id} and dateEntered>[{twelve_months_ago}]"
|
||||||
|
"&pageSize=1000"
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = requests.get(url, headers=headers, timeout=30)
|
||||||
|
entries = resp.json()
|
||||||
|
total_hours = sum(e.get('actualHours', 0) or 0 for e in entries)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Base URL
|
||||||
|
|
||||||
|
- **VistaNet internal:** `https://cw.vistanetinc.com/v4_6_release/apis/3.0`
|
||||||
|
- **Public endpoints** (`api-na.connectwise.com`) are **not reachable** from this environment.
|
||||||
|
|
||||||
|
## Known Company IDs
|
||||||
|
|
||||||
|
| Company | ID |
|
||||||
|
|---------|----|
|
||||||
|
| `<Client A>` | `<companyId>` |
|
||||||
|
| `<Client B>` | `<companyId>` |
|
||||||
|
|
||||||
|
## Summary of Errors This Fixes
|
||||||
|
|
||||||
|
| Symptom | Root Cause | Fix |
|
||||||
|
|---------|-----------|-----|
|
||||||
|
| `500 Length cannot be less than zero` | Auth header was garbage (base64 of entire .env file) | Parse key=value pairs |
|
||||||
|
| `403 Forbidden` on `/service/time` | Wrong endpoint | Use `/time/entries` |
|
||||||
|
| `None` or `0` for hours | Wrong field name | Use `actualHours` |
|
||||||
|
| `400 ApiFindCondition` for datetime | Quoted string instead of bracket syntax | Use `dateEntered>[...]` |
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# ConnectWise Schedule Entries Bug — Session Notes
|
||||||
|
|
||||||
|
## Bug Summary
|
||||||
|
The `/schedule/entries` endpoint is **non-functional** for retrieving current/future calendar data. It returns a static mirror of historical entries from 2011–2012 regardless of date filters.
|
||||||
|
|
||||||
|
## Reproduction Steps
|
||||||
|
1. Query: `GET /schedule/entries?conditions=member/id=<memberId>`
|
||||||
|
2. Result: Returns ~200 entries, all dated 2011-12-12 through 2012-03-27.
|
||||||
|
3. Add date filter: `conditions=member/id=<memberId> and dateStart>[2026-08-09T00:00:00Z]`
|
||||||
|
4. Result: **Empty response** (no entries at all).
|
||||||
|
5. Query by ticket ID: `conditions=objectId=121247`
|
||||||
|
6. Result: **Empty response** (no schedule entries returned for known active ticket).
|
||||||
|
|
||||||
|
## Attempted Workarounds (All Failed)
|
||||||
|
| Approach | Result |
|
||||||
|
|----------|--------|
|
||||||
|
| General list with `member/id` | Returns 2011 data |
|
||||||
|
| Date range filter `dateStart>[...]` | Empty response |
|
||||||
|
| Date range filter `startDate>[...]` | Empty response |
|
||||||
|
| Ticket-linked query `objectId=<id>` | Empty response |
|
||||||
|
| `pageSize=1000` | No effect on date filtering |
|
||||||
|
| Different member IDs | Same behavior |
|
||||||
|
|
||||||
|
## Server Response Characteristics
|
||||||
|
- The API returns HTTP 200 with a valid JSON array.
|
||||||
|
- No error codes or status messages indicating a problem.
|
||||||
|
- The data is simply wrong (stale by ~14 years).
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
- **Cannot retrieve current calendar/schedule data via REST API.**
|
||||||
|
- No known programmatic workaround exists.
|
||||||
|
- Technicians must use the ConnectWise web UI or desktop client for calendar access.
|
||||||
|
|
||||||
|
## Alternative Data Sources Tested
|
||||||
|
- **Nextcloud CalDAV**: Successfully queried personal calendar (`/remote.php/dav/calendars/scott/personal/`). Returns actual current events. Requires separate credentials.
|
||||||
|
- **ConnectWise Activities (`/sales/activities`)**: Tested but returned empty results for date ranges.
|
||||||
|
|
||||||
|
## Recommendation
|
||||||
|
Do not attempt to build automation around `/schedule/entries` for current calendar data. Treat this endpoint as **read-only historical reference** until ConnectWise fixes the bug.
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Fetch time entries for a ConnectWise customer and calculate total billed hours.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 fetch-customer-time-entries.py "<Client Name>"
|
||||||
|
python3 fetch-customer-time-entries.py --company-id 20172 --days 365
|
||||||
|
|
||||||
|
Requires ~/.secrets/connectwise (multi-line .env file):
|
||||||
|
CW_COMPANY_ID=VNI
|
||||||
|
CW_PUBLIC_KEY=+...
|
||||||
|
CW_PRIVATE_KEY=...
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
|
def _load_cw_client_id():
|
||||||
|
"""Resolve the ConnectWise clientId from env or ~/.secrets/connectwise."""
|
||||||
|
cid = os.getenv("CW_CLIENT_ID", "")
|
||||||
|
if cid:
|
||||||
|
return cid
|
||||||
|
path = os.path.expanduser("~/.secrets/connectwise")
|
||||||
|
if os.path.exists(path):
|
||||||
|
with open(path) as f:
|
||||||
|
for line in f:
|
||||||
|
if line.lstrip().startswith("CW_CLIENT_ID="):
|
||||||
|
return line.split("=", 1)[1].strip()
|
||||||
|
raise SystemExit(
|
||||||
|
"CW_CLIENT_ID missing: export CW_CLIENT_ID or add "
|
||||||
|
"CW_CLIENT_ID=<uuid> to ~/.secrets/connectwise"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_credentials():
|
||||||
|
path = os.path.expanduser("~/.secrets/connectwise")
|
||||||
|
if not os.path.exists(path):
|
||||||
|
print(f"Credential file not found: {path}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
with open(path, "r") as f:
|
||||||
|
raw = f.read().strip()
|
||||||
|
|
||||||
|
keys = {}
|
||||||
|
for line in raw.splitlines():
|
||||||
|
if "=" in line and not line.startswith("#"):
|
||||||
|
k, v = line.split("=", 1)
|
||||||
|
keys[k.strip()] = v.strip()
|
||||||
|
|
||||||
|
company_id = keys.get("CW_COMPANY_ID", "")
|
||||||
|
public_key = keys.get("CW_PUBLIC_KEY", "")
|
||||||
|
private_key = keys.get("CW_PRIVATE_KEY", "")
|
||||||
|
|
||||||
|
if not all([company_id, public_key, private_key]):
|
||||||
|
print("Missing required keys in credential file")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Avoid double-plus: public key already starts with '+'
|
||||||
|
auth_string = f"{company_id}+{public_key}:{private_key}"
|
||||||
|
if "++" in auth_string:
|
||||||
|
auth_string = auth_string.replace("++", "+")
|
||||||
|
|
||||||
|
encoded = base64.b64encode(auth_string.encode()).decode()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"company_id": company_id,
|
||||||
|
"auth_header": f"Basic {encoded}",
|
||||||
|
"client_id": _load_cw_client_id(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_headers(creds):
|
||||||
|
return {
|
||||||
|
"Authorization": creds["auth_header"],
|
||||||
|
"clientId": creds["client_id"],
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def find_company_by_name(base_url, headers, name):
|
||||||
|
"""Search for a company by name using a LIKE query."""
|
||||||
|
url = f"{base_url}/company/companies?conditions=name like '%{name}%'"
|
||||||
|
resp = requests.get(url, headers=headers, timeout=15)
|
||||||
|
resp.raise_for_status()
|
||||||
|
companies = resp.json()
|
||||||
|
if not companies:
|
||||||
|
return None
|
||||||
|
if len(companies) > 1:
|
||||||
|
print(f"WARNING: Found {len(companies)} companies matching '{name}':")
|
||||||
|
for c in companies[:5]:
|
||||||
|
print(f" - {c.get('name')} (ID: {c.get('id')})")
|
||||||
|
return companies[0]
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_time_entries(base_url, headers, company_id, days_back=365):
|
||||||
|
"""Fetch time entries for a company in the last N days."""
|
||||||
|
cutoff = (datetime.utcnow() - timedelta(days=days_back)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
|
||||||
|
# CRITICAL: Use square brackets for datetime values in conditions
|
||||||
|
conditions = f"company/id={company_id} and dateEntered>[{cutoff}]"
|
||||||
|
url = f"{base_url}/time/entries?conditions={conditions}&pageSize=1000"
|
||||||
|
|
||||||
|
resp = requests.get(url, headers=headers, timeout=30)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Fetch CW time entries for a customer")
|
||||||
|
parser.add_argument("name", nargs="?", help="Company name to search for")
|
||||||
|
parser.add_argument("--company-id", type=int, help="Exact ConnectWise company ID")
|
||||||
|
parser.add_argument("--days", type=int, default=365, help="Number of days back to query")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if not args.name and not args.company_id:
|
||||||
|
parser.print_help()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
creds = load_credentials()
|
||||||
|
headers = build_headers(creds)
|
||||||
|
base_url = "https://cw.vistanetinc.com/v4_6_release/apis/3.0"
|
||||||
|
|
||||||
|
if args.company_id:
|
||||||
|
company_id = args.company_id
|
||||||
|
company_name = f"ID {company_id}"
|
||||||
|
else:
|
||||||
|
company = find_company_by_name(base_url, headers, args.name)
|
||||||
|
if not company:
|
||||||
|
print(f"No company found matching '{args.name}'")
|
||||||
|
sys.exit(1)
|
||||||
|
company_id = company["id"]
|
||||||
|
company_name = company["name"]
|
||||||
|
|
||||||
|
print(f"Fetching time entries for {company_name} (ID: {company_id}) in last {args.days} days...")
|
||||||
|
entries = fetch_time_entries(base_url, headers, company_id, args.days)
|
||||||
|
|
||||||
|
# CRITICAL: The hour field is 'actualHours', not 'hours'
|
||||||
|
total_hours = sum(e.get("actualHours", 0) or 0 for e in entries)
|
||||||
|
|
||||||
|
print(f"\nTotal entries: {len(entries)}")
|
||||||
|
print(f"Total billed hours: {total_hours:.2f}")
|
||||||
|
|
||||||
|
if entries:
|
||||||
|
print("\nRecent entries:")
|
||||||
|
for e in entries[:5]:
|
||||||
|
ticket = e.get("ticket", {})
|
||||||
|
date = e.get("dateEntered", "")[:10]
|
||||||
|
hours = e.get("actualHours", 0) or 0
|
||||||
|
notes = (e.get("notes") or "No notes")[:60]
|
||||||
|
print(f" Ticket #{ticket.get('id', 'N/A')}: {hours}h on {date} — {notes}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Cross-company top N by billed hours — past year or custom range.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python top-companies-by-hours.py [DAYS] [TOP_N]
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
DAYS Number of days back to query (default: 365)
|
||||||
|
TOP_N Number of companies to show (default: 30)
|
||||||
|
|
||||||
|
Outputs a ranked table with company name, lead tech (whoever logged
|
||||||
|
most hours), and total billed actualHours.
|
||||||
|
|
||||||
|
Auth: parses ~/.secrets/connectwise (CW_COMPANY_ID, CW_PUBLIC_KEY, CW_PRIVATE_KEY)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import requests
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from collections import defaultdict
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
def load_creds(path="~/.secrets/connectwise"):
|
||||||
|
with open(os.path.expanduser(path), "r") as f:
|
||||||
|
lines = f.read().strip().split("\n")
|
||||||
|
creds = {}
|
||||||
|
for line in lines:
|
||||||
|
if "=" in line:
|
||||||
|
k, v = line.split("=", 1)
|
||||||
|
creds[k] = v
|
||||||
|
return creds
|
||||||
|
|
||||||
|
|
||||||
|
def _load_cw_client_id():
|
||||||
|
"""Resolve the ConnectWise clientId from env or ~/.secrets/connectwise."""
|
||||||
|
cid = os.getenv("CW_CLIENT_ID", "")
|
||||||
|
if cid:
|
||||||
|
return cid
|
||||||
|
path = os.path.expanduser("~/.secrets/connectwise")
|
||||||
|
if os.path.exists(path):
|
||||||
|
with open(path) as f:
|
||||||
|
for line in f:
|
||||||
|
if line.lstrip().startswith("CW_CLIENT_ID="):
|
||||||
|
return line.split("=", 1)[1].strip()
|
||||||
|
raise SystemExit(
|
||||||
|
"CW_CLIENT_ID missing: export CW_CLIENT_ID or add "
|
||||||
|
"CW_CLIENT_ID=<uuid> to ~/.secrets/connectwise"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_headers(creds):
|
||||||
|
company_id = creds["CW_COMPANY_ID"]
|
||||||
|
public_key = creds["CW_PUBLIC_KEY"]
|
||||||
|
private_key = creds["CW_PRIVATE_KEY"]
|
||||||
|
auth_str = f"{company_id}{public_key}:{private_key}"
|
||||||
|
return {
|
||||||
|
"Authorization": f"Basic {base64.b64encode(auth_str.encode()).decode()}",
|
||||||
|
"clientId": _load_cw_client_id(),
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_time_entries(headers, days_back=365):
|
||||||
|
base_url = "https://cw.vistanetinc.com/v4_6_release/apis/3.0"
|
||||||
|
cutoff = (datetime.utcnow() - timedelta(days=days_back)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
url = f"{base_url}/time/entries?conditions=timeStart%3E[{cutoff}]"
|
||||||
|
|
||||||
|
all_entries = []
|
||||||
|
page = 1
|
||||||
|
page_size = 1000
|
||||||
|
max_pages = 20
|
||||||
|
|
||||||
|
while page <= max_pages:
|
||||||
|
paged_url = f"{url}&pageSize={page_size}&page={page}"
|
||||||
|
try:
|
||||||
|
resp = requests.get(paged_url, headers=headers, timeout=30)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
print(f"API error {resp.status_code}: {resp.text[:200]}", file=sys.stderr)
|
||||||
|
break
|
||||||
|
data = resp.json()
|
||||||
|
entries = data if isinstance(data, list) else data.get("data", [])
|
||||||
|
if not entries:
|
||||||
|
break
|
||||||
|
all_entries.extend(entries)
|
||||||
|
if len(entries) < page_size:
|
||||||
|
break
|
||||||
|
page += 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Request error: {e}", file=sys.stderr)
|
||||||
|
break
|
||||||
|
|
||||||
|
return all_entries, cutoff
|
||||||
|
|
||||||
|
|
||||||
|
def aggregate(entries):
|
||||||
|
company_hours = defaultdict(float)
|
||||||
|
company_names = {}
|
||||||
|
company_member_hours = defaultdict(lambda: defaultdict(float))
|
||||||
|
|
||||||
|
for entry in entries:
|
||||||
|
comp = entry.get("company", {})
|
||||||
|
comp_id = comp.get("id")
|
||||||
|
if comp_id is None:
|
||||||
|
continue
|
||||||
|
hours = entry.get("actualHours", 0) or 0
|
||||||
|
company_hours[comp_id] += hours
|
||||||
|
company_names[comp_id] = comp.get("name", "Unknown")
|
||||||
|
|
||||||
|
member = entry.get("member", {})
|
||||||
|
member_name = member.get("name", "Unknown")
|
||||||
|
if member_name and member_name != "Unknown":
|
||||||
|
company_member_hours[comp_id][member_name] += hours
|
||||||
|
|
||||||
|
lead_techs = {
|
||||||
|
comp_id: max(members.items(), key=lambda x: x[1])[0]
|
||||||
|
for comp_id, members in company_member_hours.items()
|
||||||
|
}
|
||||||
|
return company_hours, company_names, lead_techs
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
days = int(sys.argv[1]) if len(sys.argv) > 1 else 365
|
||||||
|
top_n = int(sys.argv[2]) if len(sys.argv) > 2 else 30
|
||||||
|
|
||||||
|
creds = load_creds()
|
||||||
|
headers = get_headers(creds)
|
||||||
|
|
||||||
|
print(f"Fetching time entries from {days} days ago...", file=sys.stderr)
|
||||||
|
entries, cutoff = fetch_time_entries(headers, days)
|
||||||
|
print(f"Retrieved {len(entries)} entries", file=sys.stderr)
|
||||||
|
|
||||||
|
company_hours, company_names, lead_techs = aggregate(entries)
|
||||||
|
ranked = sorted(company_hours.items(), key=lambda x: x[1], reverse=True)[:top_n]
|
||||||
|
|
||||||
|
print(f"\n{'Rank':<6}{'Company':<50}{'Lead Tech':<25}{'Hours':>10}")
|
||||||
|
print("-" * 91)
|
||||||
|
for rank, (comp_id, hours) in enumerate(ranked, 1):
|
||||||
|
name = company_names.get(comp_id, "Unknown")
|
||||||
|
lead = lead_techs.get(comp_id, "N/A")
|
||||||
|
print(f"{rank:<6}{name:<50}{lead:<25}{hours:>10.2f}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
---
|
||||||
|
name: connectwise-msp
|
||||||
|
description: "Manage ConnectWise PSA (Manage) tickets, companies, and contacts via the REST API."
|
||||||
|
version: 1.3.0
|
||||||
|
author: Hermes Agent
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
hermes:
|
||||||
|
tags: [msp, connectwise, psa, ticketing, automation]
|
||||||
|
---
|
||||||
|
|
||||||
|
# ConnectWise MSP
|
||||||
|
|
||||||
|
Integration with ConnectWise PSA (Manage) via the REST API. Allows the agent to triage tickets, look up client information, and update ticket status directly from the chat interface.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
The skill relies on the `connectwise_api.py` script. Credentials must be set in the profile's `.env` file.
|
||||||
|
|
||||||
|
**Required Environment Variables:**
|
||||||
|
- `CW_COMPANY_ID`: Your ConnectWise company identifier.
|
||||||
|
- `CW_PUBLIC_KEY`: Your API public key.
|
||||||
|
- `CW_PRIVATE_KEY`: Your API private key.
|
||||||
|
- `CW_BASE_URL`: Your instance URL (e.g., `https://api-na.connectwise.com`).
|
||||||
|
|
||||||
|
## Real API script (VistaNet)
|
||||||
|
The bundled script only does schedule entries. The actual CW workhorse is bundled in this repo at `ops/connectwise/connectwise-msp/scripts/cw_api.py` (`cw_request(method, endpoint, params, json_data)`) with creds in `~/.secrets/connectwise` (CW_COMPANY_ID/CW_PUBLIC_KEY/CW_PRIVATE_KEY) and hardcoded base `https://cw.vistanetinc.com/v4_6_release/apis/3.0`. Run it with **system** python `/usr/bin/python3` — the default Hermes python (3.14) lacks `requests`. Tickets POST to `service/tickets` with `{company:{id}, site:{id}, board:{id}, summary, status:{id}, priority:{id}, owner:{id}, initialDescription}`. `summary` caps at 100 characters (400 `MaxLengthField "The field summary must be a string with a maximum length of 100"` otherwise) — keep the summary short and put CVE ids, version evidence and the remediation plan into `initialDescription`. Resolve `site:{id}` by copying it from one of the client's RECENT tickets — company site lists often carry a stale 'Old Site' plus two same-named 'Main' entries, and picking by name from the sites list misroutes the ticket; notes POST to `service/tickets/{id}/notes` with `{text, internalAnalysisFlag}` and NO `member` — the note posts as the API member; `member:{identifier:'…'}` 400s "member X not found" even for a real member, and `detailedDescriptionFlag` is rejected ("Could not find member … on object of type ServiceNote"). Notes-list `orderBy="_id desc"` 400s "Unrecognized orderBy field" — fetch default order and take the tail. VistaNet: board id 1 "VistaNet Help Desk", In Progress = status id **1** (not 4 — that's "Done Yet?"), 8 Must Schedule, 40 Enter Time, 439 Waiting Client Response, priority 8 "P3-Medium". chargeToType must be `ServiceTicket` (not `Ticket`) on time entries.
|
||||||
|
|
||||||
|
**Creating a ticket the user says "should already exist" — prove absence first.** The statement is a hypothesis, not a lookup result: paginate ALL of the company's tickets (`company/id = N`, orderBy `id desc`, 100/page until a short page), keyword-grep summary AND initialDescription client-side, and check sibling/related entities under the same client umbrella (a client group may span several companies; the incident could be filed under any). A recently-updated-first list misses the incident whenever nothing has touched the ticket since entry, and date-conditions are unreliable server-side (they 500) — so full-history pagination + sibling sweep is the only reliable absence proof. Only then POST the ticket; report the checked evidence (companies swept, ticket counts) rather than silently confirming a match that isn't there.
|
||||||
|
|
||||||
|
**"Get the ticket for <project>" is a find, and the finding may be ABSENCE.** Run the same absence-proof passes (full pagination + keyword sweep + sibling entities, sweeping an alternate name fragment too — spelled-out legal variants of the client hide siblings). When keywords match nothing, report that no dedicated ticket exists WITH the nearest live ticket labeled as de-facto only (usually a catch-all already touching the work) — never present the catch-all as the project ticket. Then offer create-fresh vs document-on-existing and say which billing stream each keeps clean; posting project work into an outage catch-all mixes them.
|
||||||
|
|
||||||
|
**Priority is not listable for this API member.** `GET service/boards/{id}/priorities` → 404 "The endpoint does not exist" and `GET service/priorities` → 403 — do not guess a neighboring id (P1/P2 live in that range and a guess mislabels severity). POST with the verified priority 8 (P3-Medium, board 1) or omit priority and let CW default, and tell the user which default applied so they can bump it.
|
||||||
|
|
||||||
|
**A note POST needs its own read-back:** after `POST service/tickets/{id}/notes`, GET the note collection and confirm a note containing the marker text exists with the expected `internalAnalysisFlag` — a 201 proves creation, not content retention, and the note is the user's record of what was delivered.
|
||||||
|
|
||||||
|
### Time entry POST recipe (verified TE 43240, 2026-10-09)
|
||||||
|
POST `time/entries`; mirror the shape CW returns on GET (TE 43223 introspection). Required/working fields:
|
||||||
|
```json
|
||||||
|
{"member":{"id":147}, "workType":{"id":3}, "chargeToType":"ServiceTicket", "chargeToId":<ticketId>,
|
||||||
|
"actualHours":2.5, "billableOption":"Billable", "timeStart":"...T23:00:00Z", "timeEnd":"...T01:30:00Z",
|
||||||
|
"businessUnitId":1, "locationId":11, "notes":"..."}
|
||||||
|
```
|
||||||
|
Pitfalls:
|
||||||
|
- Field is `actualHours` — `hoursActual`/`hoursBillable` DO NOT EXIST and give "Could not find member". Billable hours derive from `billableOption`.
|
||||||
|
- There is no `dateStart` on TimeEntry.
|
||||||
|
- Do NOT send `company{}` or `ticket{}` objects — CW 400s with "Member does not have rights to enter time against company." Company/ticket resolve from `chargeToId` alone.
|
||||||
|
- On TE POST, CW derives `actualHours` from the `timeStart`/`timeEnd` window — a nonzero window overrides the `actualHours` you send; to record 0.0h set the window to a zero-length (or near-zero) span, or PATCH in place afterward (verified TE 43334, 2026-10-19). NoCharge TEs follow the same body shape with `billableOption:'NoCharge'`.
|
||||||
|
- `timeStart`/`timeEnd` are UTC 'Z' ISO; 50h+ workday boundary crossing (e.g. 23:00Z→01:30Z next day) is fine.
|
||||||
|
- Asked to add a TE with no time window: GET the ticket's recent entries first (chargeToId condition). Confirm which member already logged that day — overlapping windows from DIFFERENT members are normal, but never duplicate one member's own day — read the requesting member's prior entries for their window convention, then ask them to pick among precedent-derived windows. A billable window is the member's call; never guess one.
|
||||||
|
- `billableOption` accepts `NoCharge` for no-charge work (posts 201 alongside `Billable`); confirm by reading back `GET time/entries/{id}`.
|
||||||
|
- Time entries are CORRECTABLE IN PLACE: `PATCH time/entries/{teId}` with the same JSON Patch array format tickets use (e.g. `[{'op':'replace','path':'notes','value':…}]`) — fix a typo'd server name in the notes with one replace op and re-verify through the chargeToId list query; do not void-and-repost.
|
||||||
|
|
||||||
|
### Schedule entry POST recipe (verified SE 159085, 2026-10-16)
|
||||||
|
POST `schedule/entries`; date fields are **`dateStart`/`dateEnd`** — `startDate` 400s with "Could not find member 'startDate'". Working body: `{objectId, member:{id}, type:{id:4}, status:{id:2 "Firm"}, span:{id:2}, hours, where:{id:4 "Remote"}, doneFlag, dateStart, dateEnd}` (UTC 'Z'). The `name` is auto-derived from the ticket summary and any custom name is ignored. Schedule entries are a SEPARATE object from time entries: a TE shows on the ticket's Time tab but not on the member's Schedule/calendar — post both when the user wants time documented AND visible on the schedule.
|
||||||
|
|
||||||
|
`span:{id:2}` is OFFICE HOURS: it 400s with "Start and End times must be within office hours" for any window outside the business day, so evening/night/weekend blocks need `span:{id:1}` (start-time). Don't guess a span id — copy it from a schedule entry of a previously booked after-hours ticket (`GET schedule/entries` with the `(type/id = 4 AND objectId = N)` condition); the working precedent shape is span 1 + `where:{id:4 "Remote"}` + status Firm.
|
||||||
|
|
||||||
|
### Tickets
|
||||||
|
|
||||||
|
**List Tickets**
|
||||||
|
```bash
|
||||||
|
$CW tickets list --status "New" --member "Bob"
|
||||||
|
```
|
||||||
|
Returns a JSON list of matching tickets.
|
||||||
|
|
||||||
|
**Get Ticket Details**
|
||||||
|
```bash
|
||||||
|
$CW tickets get --id "12345"
|
||||||
|
```
|
||||||
|
> The `$CW ...` commands in this section never shipped — the bundled script exposes only `cw_request()` and its helpers. Call those directly (see *Real API script (VistaNet)* above); for the recurring "does this company have a ticket today" shape, run `scripts/cw_company_ticket_audit.py` with system python3 instead of hand-writing the loop.Gets full details, including notes and time entries.
|
||||||
|
|
||||||
|
**Create Ticket**
|
||||||
|
```bash
|
||||||
|
$CW tickets create --company "Acme Corp" --summary "Internet Down" --description "Client reports total loss of connectivity" --priority "Emergency"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Update Ticket Status/Note**
|
||||||
|
```bash
|
||||||
|
$CW tickets update --id "12345" --status "In Progress" --note "Investigating the router logs."
|
||||||
|
```
|
||||||
|
|
||||||
|
### Companies & Contacts
|
||||||
|
|
||||||
|
**Search Company**
|
||||||
|
```bash
|
||||||
|
$CW companies search --query "Acme"
|
||||||
|
```
|
||||||
|
|
||||||
|
- Search with ONE distinctive name fragment (`cw_request("GET", "company/companies", params={"conditions": "name like '%Thomas%'"})`), never the user's glued shorthand ("ThomasCo CPAs") — real names carry spaces and punctuation ("Thomas & Co. CPAs, LLP"), so a glued fragment matches nothing yet still returns a clean 200 []. When several fragments match, disambiguate from the user's description of the client, not by exact-name equality.
|
||||||
|
- Spelling variants of the SAME tenant exist per punctuation/identifier drift ("X, Inc." vs "X Inc." vs "X Inc") and name-matching cannot pick between them — resolve the live one by most-recent ticket activity: pull the top ticket per candidate id (`company/id = <id>`, orderBy `id desc`, pageSize 1) and file against the company whose latest ticket is current; a dormant variant with no recent tickets must not receive the ticket.
|
||||||
|
**Get Company Details**
|
||||||
|
```bash
|
||||||
|
$CW companies get --id "guid-goes-here"
|
||||||
|
```
|
||||||
|
|
||||||
|
## cw_api.py wrapper quirks (verified 2026-10-09)
|
||||||
|
- `cw_request()` sometimes returns a **tuple `(payload, status_code)`** instead of a bare list (e.g. GET `company/configurations`, GET `time/entries/{teId}`, note POSTs) — unwrap before iterating.
|
||||||
|
- Resolve a member for reassignment via `GET system/members` with conditions `identifier = '<memberIdentifier>'` (fallback `name like`); returns integer id for PATCH owner / schedule entries. Verified reassign pattern (2026-10-19): DELETE the stale SE first, then PATCH `service/tickets/{id}` with `[{"op":"replace","path":"owner","value":{"id":<memberId>}}]`, verified by GET owner.
|
||||||
|
- `companyId` is NOT a recognized condition name on `company/configurations`; the correct field is **`company/id`** (e.g. `company/id=<companyId>`).
|
||||||
|
- Ticket PATCH requires **JSON Patch array format**: `[{'op':'replace','path':'status','value':{'id':439}}]` — a plain object gets 400 "not in the expected array format for a JSON Patch document". Status ids are PER-BOARD: never reuse a status id remembered from another board — fetch `service/boards/{ticketBoardId}/statuses` and pick the terminal status by name in priority order '>completed' → '>closed' → 'Closed' (alert boards use '>closed'/Closed; the Help Desk board uses '>Completed'). Completing a ticket = three writes, each verified on read-back: PATCH owner, POST the completion internal note, PATCH status. Verified 2026-10-09 on ticket 123536.
|
||||||
|
- `>Completed` tickets left open are NORMAL (VistaNet job process, 2026-10-19): techs place finished jobs in `>Completed` and the billing department closes them later — a pool of open `>Completed` tickets is not drift. Don't flag them as stale or propose bulk cleanup; when listing "open tickets owned by me" for scheduling, exclude status `>Completed` client-side.
|
||||||
|
- On `service/tickets`, a `dateEntered` condition containing a timezone-offset string (`...-07:00`) 500s with "unexpected database error" — query by date server-side is unreliable. Answer "entered/updated today" client-side: paginate with `page`/`pageSize` until a short page (one page of 100 is never a company-wide audit) and compare `_info.dateEntered` / `_info.lastUpdated` in local time. Those timestamps live in `_info`, not the field mask — a `fields=` list omits `dateEntered` even when you request it.
|
||||||
|
- Verification reads must not use a narrow `fields=` mask with nested projections (`status{name}`, `owner{identifier}`) — nested sub-objects can come back None even when populated, which reports a successful PATCH as failed. Re-GET without `fields=` when checking a status/owner change; likewise, when the user disputes a version or health READ, re-GET the endpoint unfiltered and show every returned key before defending the conclusion — a filtered projection is easy to accuse of a misread, and the full payload plus any independent surface settles it.- CW DateTime conditions anywhere (entry windows, ticket date columns) expect values wrapped in **square brackets**, never quotes: `dateEnd >= [2026-10-19T07:00:00Z]` — quoted strings 400 with ApiFindCondition "Expected a datetime value, not a text". Conditions must still go through the `params` dict: `cw_request("GET", "schedule/entries", params={"conditions": "(type/id = 4 AND objectId = <ticketId>)"})` (type 4 = Ticket). Concatenating the query onto the endpoint string leaves its spaces unencoded and 404s as "The endpoint does not exist". Filter the results before booking TEs: a schedule entry can be a placeholder with `hours: 0.0` and null `dateStart`/`dateEnd` — book only from entries with a real window, mirroring the TE `timeStart`/`timeEnd`/`actualHours` to that entry's `dateStart`/`dateEnd`/`hours` exactly.
|
||||||
|
|
||||||
|
### Expense entries (details: references/expense-entries.md)
|
||||||
|
Recurring ask: "show the expense items on ticket N". Query with `GET expense/entries` conditions `chargeToId = <ticketId> AND chargeToType = 'ServiceTicket'`, plus a second `ProjectTicket` pass for exhaustiveness; a report's lines come from condition `expenseReport/id = <id>` and `GET expense/reports/{id}` carries report status/total. `billAmount` = `amount` × quantity multiplier keyed on the billing side — a clean multiple of the amount is a red flag to check BEFORE it bills.
|
||||||
|
|
||||||
|
**ReadyToBill expense entries are IMMUTABLE over the API — read once, then route the fix to a human; do not iterate PATCH/DELETE variants.** Both verbs 400 with "The system cannot update/delete expense that has been closed" even when the parent report still reads Open — entry status governs editability, not report status, and no API unapprove exists. A PATCH attempt can additionally 400 "Member does not have rights to enter expense against company" (per-company expense rights the API member lacks). The human fix (billing member, before an invoice exists): Companies → <company> → Finance tab → Expenses tab → open the entry → delete it or set Billable to **Do Not Bill** (drops it from invoicing entirely; No Charge leaves a $0 line on the invoice; Write Off is a tracked write-off only for specific cases). Unchecking the row under Finance > Invoicing only defers one invoice run and the record returns next run; once an invoice is created, only a credit memo reverses it. For "did it change yet?" follow-ups, re-GET and diff `_info.lastUpdated`/`updatedBy` against the prior snapshot — a UI save changes only the timestamp, and status is what gates the next action.
|
||||||
|
|
||||||
|
### Owned-ticket last-tech audit (scripts/cw_owned_ticket_last_tech_audit.py)
|
||||||
|
Recurring ask: "tickets I own where someone else logged the last time entry". Verified 2026-10-22: resolve the member via `identifier = '<memberIdentifier>'` (one lookup), then paginate `service/tickets` with condition `owner/id = <memberId> AND closedFlag = false` (works server-side; status-name conditions not needed), then per ticket GET time/entries with the standard chargeToId condition and split: last-tech-by-work-window (max timeStart, tiebreak id) != owner / zero TEs / owner is last. Last-posted-id vs latest-window disagreement = backfill case, flagged. Takes ~1min for 90 tickets (0.25s pacing); JSON dump to scratch for follow-ups.
|
||||||
|
|
||||||
|
### Weekly schedule planning workflow (scripts/cw_week_schedule_plan.py)
|
||||||
|
Recurring request: plan a member's week on the calendar from their owned tickets.
|
||||||
|
1. PLAN first: run the script's `plan` mode (system python3) to pull the member's existing SEs for the Mon–Fri window plus all owned open tickets. Existing in-window SEs are the base calendar — never book on top of one.
|
||||||
|
2. Triage by status: any `>`-terminal (`>Completed`) = done, awaiting billing — skip; `Waiting Client Response`/`Waiting parts/repair`/`On Hold` = blocked — skip; `Enter Time` = post a TE, not an SE; `Scheduled` = verify an in-window SE actually exists (status can outlive a stale past-window entry), and confirm slipped work from notes/TEs before re-booking.
|
||||||
|
3. Size blocks from the ticket's LATEST notes, not the estimate alone: notes reading like finished work shrink the block to a short verify/follow-up; when `actualHours` already covers the work, flag for completion instead of scheduling.
|
||||||
|
4. APPLY: the `apply` mode takes a slots JSON, does move = delete + repost (attributes Firm/type4/Remote preserved), then re-GETs the window and verifies every posted ticket is present and deleted ids are absent; exits 1 on mismatch — a 201/POST success is not verification.
|
||||||
|
5. Terse reflow requests ("move to tomorrow", "take up most of the day") mean autonomously restructure the neighboring same-day blocks (delete+repost with shifted times), then report a compact day/time/ticket/work table.
|
||||||
|
- Note text is sent/returned raw; build multi-line strings with real newlines, not literal `\n` escapes.
|
||||||
|
- Filtering time/entries: a query containing `member/id=…` or `timeStart >= …` 400s. Use the condition shape CW emits in the ticket's `_info.timeentries_href` — `(chargeToType='ServiceTicket' OR chargeToType='ProjectTicket') AND chargeToId=<id>` — and filter member/date client-side. Duplicate-guard before posting: that exact query showed 1 result = only the fresh TE.
|
||||||
|
|
||||||
|
## Pitfalls & Rules
|
||||||
|
|
||||||
|
1. **ID Types:** ConnectWise uses both integer IDs (for legacy views) and GUIDs. The API primarily uses GUIDs. If a user provides a number, the script attempts to resolve it.
|
||||||
|
2. **Rate Limiting:** ConnectWise enforces strict API rate limits. Do not run large loops of `tickets get` requests in a short window.
|
||||||
|
3. **Write Permissions:** Ensure the API Member created in ConnectWise has the appropriate "Security Role" to perform the actions requested (especially for `tickets create` and `tickets update`).
|
||||||
|
4. **Timezones:** API timestamps are UTC. The agent should convert these to the user's local timezone for reporting.
|
||||||
|
5. **Notes and time entries are distinct documentation for this user:** a time entry alone never discharges a 'document this / add a note' request — post the explicit internal note and the time entry as separate writes.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# CW expense entries — lifecycle, querying, reversal
|
||||||
|
|
||||||
|
## Lifecycle
|
||||||
|
Open → (1st-tier expense approver approves; approver is set per member on Member Maintenance, Tier 2 applies to project-charged entries) → ReadyToBill → Invoiced.
|
||||||
|
- **Entry status governs API editability; the parent ExpenseReport's status (which can still read Open) does not.**
|
||||||
|
- `billAmount` = `invoiceAmount` as the customer-facing bill = `amount` × quantity multiplier keyed on the billing side. A clean multiple of `amount` = quantity error — check before it bills.
|
||||||
|
|
||||||
|
## Query recipes
|
||||||
|
- Ticket-charged expenses: `GET expense/entries`, conditions `chargeToId = <ticketId> AND chargeToType = 'ServiceTicket'`; repeat with `'ProjectTicket'` to be exhaustive.
|
||||||
|
- Report contents: conditions `expenseReport/id = <id>`; report meta via `GET expense/reports/{id}` (status, total, member, dueDate).
|
||||||
|
|
||||||
|
## API editability decision table
|
||||||
|
| Entry status | PATCH/DELETE attempt |
|
||||||
|
|---|---|
|
||||||
|
| Open | Allowed — but may still 400 "Member does not have rights to enter expense against company" (per-company expense rights of the API member) |
|
||||||
|
| ReadyToBill | 400 "The system cannot update/delete expense that has been closed" — no API unapprove exists; route to human |
|
||||||
|
| Invoiced | 400; reversal is a credit memo through billing |
|
||||||
|
|
||||||
|
## Human reversal path (billing member, entry not yet invoiced)
|
||||||
|
1. **Companies → <company> → Finance tab → Expenses tab** → open the entry (approved records remain editable until an invoice is created).
|
||||||
|
2. Choose: **delete** the entry, or set **Billable = Do Not Bill** (removes it from invoicing entirely — usually the target), **No Charge** (stays on the invoice at $0.00 — NOT removal), or **Write Off** (toolbar tracked write-off — reserve for specific scenarios).
|
||||||
|
3. Fix the quantity multiplier / charge-to on the same record while it is open.
|
||||||
|
4. **NOT the fix:** unchecking the row under Finance > Invoicing — that holds it back for one run only; the ReadyToBill record returns on the next invoice run.
|
||||||
|
5. Once **Create Invoices** has run, only a **credit memo** reverses it — act before the invoice run touches the company.
|
||||||
|
|
||||||
|
## Recheck pattern ("how about now?" follow-ups)
|
||||||
|
Re-GET the entry and diff `_info.lastUpdated`/`updatedBy` against the prior snapshot before concluding nothing moved. A UI save changes only the timestamp; the `status` field is what gates the next action. Do not spin up a background poller — re-check on the user's word.
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import requests
|
||||||
|
import base64
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
# Load environment variables
|
||||||
|
CW_URL = os.getenv("CW_BASE_URL", "https://api-na.connectwise.com")
|
||||||
|
CW_COMPANY = os.getenv("CW_COMPANY", "VNI")
|
||||||
|
CW_PUBLIC_KEY = os.getenv("CW_PUBLIC_KEY", "")
|
||||||
|
CW_PRIVATE_KEY = os.getenv("CW_PRIVATE_KEY", "")
|
||||||
|
CW_CLIENT_ID = os.getenv("CW_CLIENT_ID", "")
|
||||||
|
|
||||||
|
def get_auth_header():
|
||||||
|
auth_str = f"{CW_COMPANY}+{CW_PUBLIC_KEY}:{CW_PRIVATE_KEY}"
|
||||||
|
encoded = base64.b64encode(auth_str.encode()).decode()
|
||||||
|
return f"Basic {encoded}"
|
||||||
|
|
||||||
|
def create_schedule_entry(ticket_id, member_id, start_local, end_local):
|
||||||
|
endpoint = f"{CW_URL}/schedule/entries"
|
||||||
|
|
||||||
|
# ConnectWise expects UTC.
|
||||||
|
# Pacific Time (PDT) is UTC-7.
|
||||||
|
# To convert 8:00 AM PDT to UTC, we add 7 hours.
|
||||||
|
|
||||||
|
# The user input is expected as "YYYY-MM-DDTHH:MM:SS"
|
||||||
|
start_dt = datetime.strptime(start_local, "%Y-%m-%dT%H:%M:%S")
|
||||||
|
end_dt = datetime.strptime(end_local, "%Y-%m-%dT%H:%M:%S")
|
||||||
|
|
||||||
|
utc_start = (start_dt + timedelta(hours=7)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
utc_end = (end_dt + timedelta(hours=7)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"member": {
|
||||||
|
"id": int(member_id)
|
||||||
|
},
|
||||||
|
"dateStart": utc_start,
|
||||||
|
"dateEnd": utc_end,
|
||||||
|
"objectId": int(ticket_id),
|
||||||
|
"type": {
|
||||||
|
"id": 4
|
||||||
|
},
|
||||||
|
"span": {
|
||||||
|
"id": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Authorization": get_auth_header(),
|
||||||
|
"clientId": CW_CLIENT_ID,
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(endpoint, headers=headers, json=payload, timeout=30)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": str(e), "body": getattr(e.response, 'text', 'No body')}
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("action", choices=["schedule"])
|
||||||
|
parser.add_argument("--ticket_id")
|
||||||
|
parser.add_argument("--member_id")
|
||||||
|
parser.add_argument("--start") # Format: 2026-07-03T08:00:00
|
||||||
|
parser.add_argument("--end") # Format: 2026-07-03T08:30:00
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.action == "schedule":
|
||||||
|
result = create_schedule_entry(args.ticket_id, args.member_id, args.start, args.end)
|
||||||
|
print(json.dumps(result, indent=2))
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""ConnectWise company ticket audit: did anything enter or get updated TODAY
|
||||||
|
(or another America/Los_Angeles day) for a company, plus schedule entries and
|
||||||
|
date detail for a single ticket. Encodes every cw_api.pv lookup quirk that
|
||||||
|
direct date/keyword queries hit differently.
|
||||||
|
|
||||||
|
Usage (system python only; needs requests + creds in ~/.secrets/connectwise):
|
||||||
|
/usr/bin/python3 cw_company_ticket_audit.py <company name fragment> [ticket id]
|
||||||
|
"""
|
||||||
|
import json, os, sys
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.expanduser("~/.hermes/scripts"))
|
||||||
|
from cw_api import cw_request # noqa: E402
|
||||||
|
|
||||||
|
LOCAL_TZ = ZoneInfo("America/Los_Angeles")
|
||||||
|
|
||||||
|
|
||||||
|
def find_companies(fragment):
|
||||||
|
"""One distinctive fragment, not the user's glued shorthand."""
|
||||||
|
d, s = cw_request("GET", "company/companies", params={
|
||||||
|
"conditions": "name like '%{}%'".format(fragment),
|
||||||
|
"fields": "id,identifier,name",
|
||||||
|
})
|
||||||
|
if s != 200 or not isinstance(d, list):
|
||||||
|
sys.exit("company search failed: {} {}".format(s, d))
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def all_tickets(company_id):
|
||||||
|
"""dateEntered is not in the field mask and offset-timezone conditions 500,
|
||||||
|
so fetch unfiltered pages and filter client-side."""
|
||||||
|
out, page = [], 1
|
||||||
|
while True:
|
||||||
|
d, s = cw_request("GET", "service/tickets", params={
|
||||||
|
"conditions": "company/id = {}".format(company_id),
|
||||||
|
"pageSize": 100, "page": page, "orderBy": "id desc",
|
||||||
|
})
|
||||||
|
if s != 200 or not isinstance(d, list):
|
||||||
|
sys.exit("ticket list failed at page {}: {} {}".format(page, s, d))
|
||||||
|
out.extend(d)
|
||||||
|
if len(d) < 100:
|
||||||
|
return out
|
||||||
|
page += 1
|
||||||
|
|
||||||
|
|
||||||
|
def parse_utc(v):
|
||||||
|
return datetime.fromisoformat(v.replace("Z", "+00:00"))
|
||||||
|
|
||||||
|
|
||||||
|
def audit_today(all_t, day):
|
||||||
|
start = datetime(day.year, day.month, day.day, tzinfo=LOCAL_TZ)
|
||||||
|
end = start + timedelta(days=1)
|
||||||
|
hits = {}
|
||||||
|
for t in all_t:
|
||||||
|
info = t.get("_info", {})
|
||||||
|
for k in ("dateEntered", "lastUpdated"):
|
||||||
|
v = info.get(k)
|
||||||
|
if not v:
|
||||||
|
continue
|
||||||
|
dt = parse_utc(v)
|
||||||
|
if start <= dt < end:
|
||||||
|
hits[t["id"]] = {
|
||||||
|
"id": t["id"], "summary": t.get("summary"),
|
||||||
|
"status": (t.get("status") or {}).get("name"),
|
||||||
|
"dateEntered": info.get("dateEntered"),
|
||||||
|
"lastUpdated": info.get("lastUpdated"),
|
||||||
|
"updatedBy": info.get("updatedBy"), "matched": k,
|
||||||
|
}
|
||||||
|
return sorted(hits.values(), key=lambda h: h["id"], reverse=True)
|
||||||
|
|
||||||
|
|
||||||
|
def schedule_for(ticket_id):
|
||||||
|
"""Conditions via the params dict; type 4 = Ticket."""
|
||||||
|
d, s = cw_request("GET", "schedule/entries", params={
|
||||||
|
"conditions": "(type/id = 4 AND objectId = {})".format(ticket_id)})
|
||||||
|
if s != 200 or not isinstance(d, list):
|
||||||
|
return {"error": "{} {}".format(s, d)}
|
||||||
|
return [{k: e.get(k) for k in ("dateStart", "dateEnd", "where") if k in e}
|
||||||
|
for e in d]
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
sys.exit(__doc__)
|
||||||
|
fragment, inspect_id = sys.argv[1], (int(sys.argv[2]) if len(sys.argv) > 2 else None)
|
||||||
|
companies = find_companies(fragment)
|
||||||
|
print(json.dumps(companies, indent=1, default=str))
|
||||||
|
for c in companies:
|
||||||
|
cid, cname = c["id"], c.get("name")
|
||||||
|
tickets = all_tickets(cid)
|
||||||
|
print("\n# {} (id {}): {} tickets".format(cname, cid, len(tickets)))
|
||||||
|
print(json.dumps(audit_today(tickets, datetime.now(LOCAL_TZ).date()),
|
||||||
|
indent=1, default=str))
|
||||||
|
if inspect_id:
|
||||||
|
t = next((x for x in tickets if x["id"] == inspect_id), None)
|
||||||
|
if not t:
|
||||||
|
continue
|
||||||
|
info = t.get("_info", {})
|
||||||
|
print(json.dumps({"id": inspect_id, "summary": t.get("summary"),
|
||||||
|
"dateEntered": info.get("dateEntered"),
|
||||||
|
"lastUpdated": info.get("lastUpdated"),
|
||||||
|
"schedule": schedule_for(inspect_id)},
|
||||||
|
indent=1, default=str))
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
"""
|
||||||
|
ConnectWise weekly schedule planner for a member.
|
||||||
|
|
||||||
|
plan mode: python3 cw_week_schedule_plan.py plan <memberId> [--week YYYY-MM-DD] [--out FILE]
|
||||||
|
Fetch the member's existing schedule entries for the Mon-Fri week window and all open
|
||||||
|
tickets owned by the member. Prints triaged tables; saves JSON when --out is given.
|
||||||
|
|
||||||
|
apply mode: python3 cw_week_schedule_plan.py apply <memberId> <slots.json>
|
||||||
|
slots.json: {"week_start": "YYYY-MM-DD" (the Monday), "deletes": [seId, ...],
|
||||||
|
"slots": [{"ticket": 123456, "offset": 0, "start": "09:00", "end": "11:00", "hours": 2.0}, ...]}
|
||||||
|
Deletes listed schedule entries, POSTs the new ones (America/Los_Angeles -> UTC 'Z'),
|
||||||
|
then re-GETs the week window and verifies: every posted ticket present, every deleted
|
||||||
|
id absent. Exits 1 on any mismatch.
|
||||||
|
|
||||||
|
Run with system python3 (/usr/bin/python3) — cw_api needs `requests`.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from collections import Counter
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
sys.path.insert(0, '/home/tech/.hermes/scripts')
|
||||||
|
from cw_api import cw_request
|
||||||
|
|
||||||
|
TZ = ZoneInfo('America/Los_Angeles')
|
||||||
|
|
||||||
|
|
||||||
|
def unwrap(r):
|
||||||
|
return r[0] if isinstance(r, tuple) else r
|
||||||
|
|
||||||
|
|
||||||
|
def z(dt):
|
||||||
|
return dt.astimezone(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||||
|
|
||||||
|
|
||||||
|
def week_start(date_str=None):
|
||||||
|
if date_str:
|
||||||
|
d = datetime.strptime(date_str, '%Y-%m-%d').date()
|
||||||
|
else:
|
||||||
|
t = datetime.now(TZ).date()
|
||||||
|
d = t - timedelta(days=t.weekday())
|
||||||
|
return datetime(d.year, d.month, d.day, tzinfo=TZ)
|
||||||
|
|
||||||
|
|
||||||
|
def fmt_local(iso):
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(iso.split('.')[0].replace('Z', '+00:00')).astimezone(TZ)
|
||||||
|
return dt.strftime('%a %m-%d %I:%M%p').lower()
|
||||||
|
except Exception:
|
||||||
|
return str(iso)[:16]
|
||||||
|
|
||||||
|
|
||||||
|
def sched_class(st):
|
||||||
|
n = (st or '').lower().strip()
|
||||||
|
if n.startswith('>') or n in ('closed',):
|
||||||
|
return 'skip'
|
||||||
|
if n.startswith('waiting') or n.startswith('on hold'):
|
||||||
|
return 'skip'
|
||||||
|
if n == 'enter time':
|
||||||
|
return 'time-only'
|
||||||
|
if n == 'scheduled':
|
||||||
|
return 'verify'
|
||||||
|
return 'plan'
|
||||||
|
|
||||||
|
|
||||||
|
def do_plan(a):
|
||||||
|
ws = week_start(a.week)
|
||||||
|
we = ws + timedelta(days=7)
|
||||||
|
print(f"Member {a.member}; week of {ws.date()} (Mon-Fri); window UTC [{z(ws)}] -> [{z(we)}]")
|
||||||
|
entries = unwrap(cw_request('GET', 'schedule/entries', params={
|
||||||
|
'conditions': f"(member/id = {a.member} AND (dateEnd >= [{z(ws)}] AND dateStart <= [{z(we)}]))",
|
||||||
|
'pageSize': '500'})) or []
|
||||||
|
print(f"\n=== Existing schedule entries ({len(entries)}) — the base calendar ===")
|
||||||
|
for e in sorted(entries, key=lambda x: x.get('dateStart') or ''):
|
||||||
|
print(f" #{e.get('id')} [{fmt_local(e.get('dateStart'))} -> {fmt_local(e.get('dateEnd'))}] "
|
||||||
|
f"{e.get('hours') or 0}h ticket={e.get('objectId')} :: {(e.get('name') or '')[:60]}")
|
||||||
|
|
||||||
|
tickets, page = [], 1
|
||||||
|
while True:
|
||||||
|
t = unwrap(cw_request('GET', 'service/tickets', params={
|
||||||
|
'conditions': f'owner/id = {a.member} AND closedFlag = false',
|
||||||
|
'pageSize': '100', 'page': str(page)})) or []
|
||||||
|
tickets += t
|
||||||
|
if len(t) < 100:
|
||||||
|
break
|
||||||
|
page += 1
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for t in tickets:
|
||||||
|
rows.append({
|
||||||
|
'id': t.get('id'),
|
||||||
|
'summary': t.get('summary'),
|
||||||
|
'status': (t.get('status') or {}).get('name') or '',
|
||||||
|
'priority': (t.get('priority') or {}).get('name') or '',
|
||||||
|
'company': (t.get('company') or {}).get('name') or '',
|
||||||
|
'lastUpdated': (t.get('_info') or {}).get('lastUpdated'),
|
||||||
|
'actualHours': t.get('actualHours'),
|
||||||
|
'budgetHours': t.get('budgetHours'),
|
||||||
|
})
|
||||||
|
|
||||||
|
def pri(p):
|
||||||
|
m = re.search(r'\d+', p or '')
|
||||||
|
return int(m.group()) if m else 9
|
||||||
|
|
||||||
|
rows.sort(key=lambda r: (pri(r['priority']), r['status'], (r['lastUpdated'] or '')))
|
||||||
|
print(f"\n=== Owned open tickets ({len(rows)}) — status counts ===")
|
||||||
|
for s, c in Counter(r['status'] or '(none)' for r in rows).most_common():
|
||||||
|
print(f" {s}: {c}")
|
||||||
|
print("\n=== Schedulable (plan / verify-in-window / time-entry-only) ===")
|
||||||
|
for r in rows:
|
||||||
|
c = sched_class(r['status'])
|
||||||
|
if c == 'skip':
|
||||||
|
continue
|
||||||
|
print(f" [{c.upper():9}] {r['id']} {(r['priority'])[:13]:<13} {r['company'][:24]:<24} "
|
||||||
|
f"{r.get('budgetHours') or 0}b/{r.get('actualHours') or 0}a h up {fmt_local(r['lastUpdated'])} :: {(r['summary'] or '')[:50]}")
|
||||||
|
|
||||||
|
if a.out:
|
||||||
|
json.dump({'week_start': z(ws), 'member': a.member, 'entries': entries, 'tickets': rows},
|
||||||
|
open(a.out, 'w'), default=str, indent=1)
|
||||||
|
print(f"\nSaved JSON -> {a.out}")
|
||||||
|
|
||||||
|
|
||||||
|
def do_apply(a):
|
||||||
|
spec = json.load(open(a.slots))
|
||||||
|
ws = week_start(spec.get('week_start'))
|
||||||
|
we = ws + timedelta(days=7)
|
||||||
|
if ws.strftime('%A') != 'Monday':
|
||||||
|
print(f"WARNING: {ws.date()} is a {ws.strftime('%A')} — pass the Monday of the target week.")
|
||||||
|
|
||||||
|
for sid in spec.get('deletes', []):
|
||||||
|
unwrap(cw_request('DELETE', f'schedule/entries/{sid}'))
|
||||||
|
print(f"DELETE SE {sid} ok")
|
||||||
|
|
||||||
|
posted = []
|
||||||
|
for s in spec.get('slots', []):
|
||||||
|
day = ws + timedelta(days=int(s.get('offset', 0)))
|
||||||
|
sh, sm = map(int, s['start'].split(':'))
|
||||||
|
eh, em = map(int, s['end'].split(':'))
|
||||||
|
mins = (eh * 60 + em) - (sh * 60 + sm)
|
||||||
|
if mins <= 0:
|
||||||
|
mins += 1440
|
||||||
|
hours = float(s['hours']) if s.get('hours') is not None else round(mins / 60, 2)
|
||||||
|
sd = datetime(day.year, day.month, day.day, sh, sm, tzinfo=TZ)
|
||||||
|
ed = datetime(day.year, day.month, day.day, eh, em, tzinfo=TZ)
|
||||||
|
if ed <= sd:
|
||||||
|
ed += timedelta(days=1)
|
||||||
|
body = {'objectId': int(s['ticket']), 'member': {'id': a.member}, 'type': {'id': 4},
|
||||||
|
'status': {'id': 2}, 'span': {'id': 2}, 'hours': hours, 'where': {'id': 4},
|
||||||
|
'doneFlag': False, 'dateStart': z(sd), 'dateEnd': z(ed)}
|
||||||
|
r = unwrap(cw_request('POST', 'schedule/entries', json_data=body))
|
||||||
|
if isinstance(r, dict) and r.get('code'):
|
||||||
|
print(f"POST FAIL tid={s['ticket']}: {r}")
|
||||||
|
sys.exit(1)
|
||||||
|
posted.append(int(s['ticket']))
|
||||||
|
print(f"POSTED SE {r.get('id')} tid={s['ticket']} {day:%a %m-%d} "
|
||||||
|
f"{s['start']}->{s['end']} ({hours}h)")
|
||||||
|
|
||||||
|
rows = unwrap(cw_request('GET', 'schedule/entries', params={
|
||||||
|
'conditions': f"(member/id = {a.member} AND (dateEnd >= [{z(ws)}] AND dateStart <= [{z(we)}]))",
|
||||||
|
'pageSize': '500'})) or []
|
||||||
|
ids = {e.get('id') for e in rows}
|
||||||
|
tid_present = {e.get('objectId') for e in rows}
|
||||||
|
print(f"\nRead-back: {len(rows)} entries this week")
|
||||||
|
for e in sorted(rows, key=lambda x: x.get('dateStart') or ''):
|
||||||
|
print(f" #{e.get('id')} [{fmt_local(e.get('dateStart'))} -> {fmt_local(e.get('dateEnd'))}] "
|
||||||
|
f"{e.get('hours') or 0}h ticket={e.get('objectId')} :: {(e.get('name') or '')[:55]}")
|
||||||
|
problems = []
|
||||||
|
for t in posted:
|
||||||
|
if t not in tid_present:
|
||||||
|
problems.append(f"ticket {t} missing from calendar after POST")
|
||||||
|
for sid in spec.get('deletes', []):
|
||||||
|
if sid in ids:
|
||||||
|
problems.append(f"SE {sid} still present after DELETE")
|
||||||
|
if problems:
|
||||||
|
print("VERIFY MISMATCH:")
|
||||||
|
for p in problems:
|
||||||
|
print(f" - {p}")
|
||||||
|
sys.exit(1)
|
||||||
|
print("VERIFY OK")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(description='CW weekly schedule planner')
|
||||||
|
sub = ap.add_subparsers(dest='mode', required=True)
|
||||||
|
p1 = sub.add_parser('plan')
|
||||||
|
p1.add_argument('member', type=int)
|
||||||
|
p1.add_argument('--week', default=None, help='Monday of target week, YYYY-MM-DD (default: current week)')
|
||||||
|
p1.add_argument('--out', default=None, help='Save fetched JSON to this file')
|
||||||
|
p2 = sub.add_parser('apply')
|
||||||
|
p2.add_argument('member', type=int)
|
||||||
|
p2.add_argument('slots', help='slots.json path')
|
||||||
|
a = ap.parse_args()
|
||||||
|
(do_plan if a.mode == 'plan' else do_apply)(a)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
---
|
||||||
|
name: cw-triage-workflow
|
||||||
|
description: Use for ConnectWise triage n8n workflow builds.
|
||||||
|
---
|
||||||
|
|
||||||
|
# ConnectWise Triage Workflow (n8n SDK)
|
||||||
|
|
||||||
|
Context: SOLE live copy at the WORK n8n instance — workflow `<liveWorkflowId>` ('ConnectWise Triage v3.0 - LLM Tiers'; project `<projectId>` 'Tech Services', CW credential `<cwCredentialId>` 'ConnectWise PSA account', audit table `<auditTableId>` cw_triage_audit, verified exec pinned). Lookup ids at runtime; never hardcode from history. 2026-10-07: a former live workflow id was found ARCHIVED; the current one is the only accessible/non-archived triage workflow on the work server — run that one. It publishes modelVersion v3.1-llm-observe. Home-lab copies all archived 2026-10-05 (v2.2 + v1 ids + 4 scratch) — the home lab is no longer authoritative for anything triage-related. Notes post as member `n8nvni` on work, `SBrown` on home lab. Related: `connectwise-n8n-automation` for CW API patterns.
|
||||||
|
|
||||||
|
## Running it (verified 2026-10-19, exec pinned)
|
||||||
|
|
||||||
|
- **`execute_workflow(executionMode:'production')` FAILS — workflow has no published version.** Run it with `executionMode:'manual'` (current version, no inputs; single manual trigger). Manual runs ARE real runs — notes/audit rows write live. For a scheduled future it needs publish_workflow first.
|
||||||
|
- **MCP server gotcha: the default n8n MCP server does NOT have this workflow** (`execute_workflow` returns 'Workflow not found'). Run it via the `n8n_work` server (mcp__n8n_work__*) — search_workflows confirms it there.
|
||||||
|
- Typical full-pipe runtime ~8–65s (T1-only runs ~8–20s; a T3 security-review pass adds ~10s). Wait ~25–45s before the first status poll — polling a fresh run immediately returns 'running' every time and burns a round-trip. Recent manual runs: 1197 (7.7s T1-only, 3 posted), 1199 (19s incl. T3, 1 posted / 2 deduped), 1200 (8s T1-only, 3 posted).
|
||||||
|
|
||||||
|
## Proven SDK patterns (all verified live 2026-10-04)
|
||||||
|
|
||||||
|
- CW fetch (custom getAll): node type `@mspcopilot/n8n-nodes-connectwise.connectWisePsa` v1, `resource: 'custom'`, `operation: 'getAll'`, `endpoint: '/service/tickets'`, `conditions: "closedFlag = false AND board/name = \"VistaNet Help Desk\""`, `orderBy: 'lastUpdated desc'`, `limit`, `fields: 'id,summary,status,name,company/name,board/name,_info'`. Credential ref: `credentials: { connectWisePsaApi: { id: '<cwCredentialId>', name: 'ConnectWise PSA account' } }` (resolve the real id from the work n8n at runtime).
|
||||||
|
- CW ticketNote create: same connector, `resource: 'ticketNote'`, `operation: 'create'`, `ticketType: 'service'`, `parentId: expr('{{ $json.ticketId }}')`, `text: expr("{{ $json.note }}")`, `internalAnalysisFlag: true`, `processNotifications: false` → posts an INTERNAL note (member = credential's member, e.g. SBrown)..
|
||||||
|
- Audit dedupe: `n8n-nodes-base.dataTable` v1.1, `resource: 'row'`, `operation: 'rowNotExists'`, `dataTableId: { __rl: true, mode: 'name', value: 'cw_triage_audit' }`, `matchType: 'allConditions'`, filters conditions with `keyName/condition/keyValue` + `expr('…')`. Works — correctly suppresses both items when rows exist, passes both when table empty.
|
||||||
|
- Table insert: same node, `operation: 'insert'`, `columns: { mappingMode: 'defineBelow', value: {...}, schema: [...] }`. Table must EXIST before create_workflow_from_code (build it with create_data_table; needs projectId; row-existence ops accept numeric keys for number columns).
|
||||||
|
- Cross-node field refs: after a write node (note create), later nodes must read `$('Build Triage Draft').item.json.X`, not `$json.X` — validator warns on `$json.*` passthrough of write-node outputs.
|
||||||
|
- Testing: `test_workflow` requires pinData. Pin ONLY the manual trigger (`'On Test': [{ json: {} }]`) to force every downstream node to execute for real. Pinning a data node (fetch) replaces its live output with fakes — notes/rows still write to real targets, so real tickets got stand-in text once; avoid.
|
||||||
|
- Verify with `get_workflow_execution(workflowId, executionId, includeData: true)` for each runData node, then `get_data_table_rows(dataTableId, projectId)` read-back — never trust node status alone.
|
||||||
|
- Full-graph `includeData` responses spill (>140KB) to a persisted-output file — parse the local file with Python instead of re-requesting the API. runData REPEATS node names once per parallel LLM fan-out item (e.g. 'T1 Primary' ×3); dedupe node names when summarizing what ran.
|
||||||
|
- Filter the audit read-back on `ticketId >= <lowest fetched id>` AND `modelVersion = <live version>` — one query separates new rows from prior-run suppressions, and every suppression should have a real row behind it. Pull per-ticket detail (notes read-back / note nodes) before summarizing: the audit row alone won't surface suggested assignees or T3 narratives.
|
||||||
|
|
||||||
|
## Scope gate (Scott, 2026-10-04)
|
||||||
|
|
||||||
|
- Triage ONLY tickets with status `New` or `Tier 3 Assigned` on VistaNet Help Desk. Fetch conditions: `(status/name = 'New' OR status/name = 'Tier 3 Assigned') AND closedFlag = false AND board/name = 'VistaNet Help Desk'`. CW conditions accept single-quoted string literals; parens group OR before AND.
|
||||||
|
- Verified exec pinned: In Progress tickets no longer enter the pipe; dedupe suppressed already-triaged tickets when limit was raised to 4, passing the next 2 in-scope through (matching audit rows + notes).
|
||||||
|
- RESOLVED (2026-10-07, live in the sole triage workflow): TinMan auto-created tickets are EXCLUDED at fetch — conditions include `AND enteredBy != 'TinMan'`. Do not remove.
|
||||||
|
|
||||||
|
## v2.2 notes-aware classification (archived home-lab workflow, verified exec pinned)
|
||||||
|
|
||||||
|
- Fetch last 10 notes per ticket via `endpoint: expr("{{ '/service/tickets/' + $json.id + '/notes' }}")`, `limit: 10, orderBy: 'id', descending: true`. Notes carry their own `ticketId` field — group on that in the Code node; `pairedItem` indexing is unreliable for ticket→notes fan-out.
|
||||||
|
- Filter notes containing '[TinMan' (own notes) before classifying — otherwise the classifier reads its own drafts and dedupe churns.
|
||||||
|
- Score summary and notes separately; note-sourced evidence beats summary-sourced (note-anchored → confidence 0.9, summary-only → 0.8, no cue → 'general' 0.55). Evidence line cites the note id, e.g. `note <noteId>: "Upgrade Exchange Server to 2019 SE..."`.
|
||||||
|
- On-prem vs M365 Exchange disambiguation lives in note content, not the summary line — summary 'Exchange Server' is ambiguous, note 'Upgrade Exchange Server to 2019 SE and migrate mailboxes' is on-prem. Regexes: ONPREM `/exchange (server|20\d\d|se\b)|on-?prem(ise)?s? exchange|exchange hybrid|mailbox migration|public folder/i` BEFORE M365 cues.
|
||||||
|
- Audit table gained `classificationBasis` column (string); modelVersion `v2.1-observe` scopes dedupe separately from v1 — re-runs of the same version are suppressed, new versions post again.
|
||||||
|
- Validator still warns INVALID_EXPRESSION_PATH on Code-node output fields (can't infer Code outputs) — benign, runtime proves it; don't chase it.
|
||||||
|
|
||||||
|
## v1 observe-mode contract
|
||||||
|
|
||||||
|
- Notes ONLY (internalAnalysisFlag) + one audit row per ticket on the `<auditTableId>` cw_triage_audit table. No board/status/routing writes. Note text always starts `[TinMan v1 OBSERVE]`.
|
||||||
|
- Deterministic stand-in classifier (regex tripwire + keyword stage-1) is inside `Build Triage Draft` Code node — LLM tiers (qwen3:4b local / glm-5.3-flash cloud / kimi-k2.6 cloud security path, 5 confidence gates) wire in AFTER the pipe is proven.
|
||||||
|
- Production switch = remove id filter, add 15-min schedule trigger, then gate flips per Scott's go.
|
||||||
|
- Audit table columns: ticketId, ticketSummary, ticketStatus, stage1, confidence, tripwireHit, tripwireReason, team, notePosted, modelVersion, testedAt.
|
||||||
|
|
||||||
|
## Note prefix: Board Ops (2026-10-09 rebrand, verified exec pinned)
|
||||||
|
|
||||||
|
- Note tag is `[Board Ops v3 OBSERVED]` (was `[TinMan v3 OBSERVE]`). CW member `n8nvni` display name IS "Board Ops", so notes appear in CW posted by Board Ops — matches the tag.
|
||||||
|
- Self-note skip in Prepare Triage Items must be a DUAL-PREFIX regex: `const SELF_NOTE_RX = /[\\[]TinMan|[\\[]Board Ops/;` — old TinMan notes still exist on tickets and must stay excluded. A rename without widening the filter would feed own notes to the classifier.
|
||||||
|
- Fetch condition `enteredBy != 'TinMan'` stays — that's the CW member identity, not note text.
|
||||||
|
- Renames require a modelVersion bump (dedupe keys on ticketId+modelVersion): live modelVersion is now `v3.2-boardops-observe`. Matching audit rows + notes posted under it; old v3.1 notes left in place on the affected tickets.
|
||||||
|
- Safe edits of long one-line jsCode: push via setNodeParameter with `\\b` etc. typed raw (matches how API serializes regexes); always read back full workflow and diff substrings before trusting it — reconstructing jsCode from chat memory drifts (lost a `.filter(Boolean)` once).
|
||||||
|
|
||||||
|
## v3.0 LLM cascade (test + live pair, 2026-10-05)
|
||||||
|
|
||||||
|
- Tier map (proven exec pinned): **T1 = gpt-oss:20b via Ollama Cloud** (`lmChatOllama` v1, model `gpt-oss:20b`, options num_predict 350 / temperature 0.2, credential `<n8nCredentialId>`) — 3.3 s/ticket on the real T1 prompt, clean minified JSON. T2 = glm-5.3-flash, T3 = kimi-k2.6 (both cloud, same credential). Local qwen3:4b on a `<lan-ip>` host = BACKUP chain only (multi-minute latency on M4000, unusable as primary).
|
||||||
|
- Ollama Cloud catalog (verified by probe 1102-1107): tiny models DO NOT exist — qwen3:1.7b/0.6b, gemma3:1b all 404. Smallest is gpt-oss:20b (13.8 GB MoE); next nemotron-3-nano:30b measured 26.5 s — 8× slower. Don't re-probe.
|
||||||
|
- Cascade: T1 → Evaluate → cloud-fail/parse-empty ⇒ 'Use Local T1?' IF (boolean expr `$json.localFallback`) → local qwen3:4b backup chain → Fold T1 Backup → Merge append → Needs T2 (conf<0.5/parse fail) → glm → Final Classification → Needs T3 (tripwire|security|urgency≥5) → kimi → Build Triage Draft → rowNotExists dedupe → CW note → audit row.
|
||||||
|
- CRITICAL n8n chain-node pairing rule: `$('Node').item` after parallel LLM fan-in mis-pairs ALL items onto item #1 (exec pinned: 6 identical notes). ALWAYS zip by pairedItem: `const idx = row.pairedItem && typeof row.pairedItem.item === 'number' ? row.pairedItem.item : i; const base = $('Source').all(TREE_OUTPUT_INDEX)[idx].json;` — pass the branch output index to `.all(0/1)` for IF branches.
|
||||||
|
- IF v2.2 string-equals conditions silently match NOTHING in some SDK-built nodes (exec pinned: routed all 10 items to false branch). Use boolean-expression conditions: leftValue `={{ $json.x === 'val' }}`, operator boolean/true.
|
||||||
|
- Cleanup of CW notes: connector NATIVE delete (`resource: 'ticketNote', operation: 'delete', ticketType: 'service', parentId + id`) works; custom endpoint DELETE `/service/tickets/{id}/notes/{noteId}` 404s. Data table row delete: op name is `deleteRows` (NOT delete), filters matchType allConditions per keyName/condition/keyValue; no MCP row-delete tool exists — use a workflow.
|
||||||
|
- Pinned test runs write REAL audit rows for FAKE pinned tickets — delete them after (deleteRows in a throwaway, then archive both probe workflows). A pinned-test note for fake tickets 999887/999888 was pinned (no CW write) but its audit row (fake ticket 999888) had to be removed.
|
||||||
|
|
||||||
|
## Suggested-assignee heuristic (v3.0, exec pinned)
|
||||||
|
|
||||||
|
- CW member skills ARE populated (2026-10-04; six members carry skills — counts 3-11 each, roster in a local skills dump — while 15 members are skillless integration/admin accounts). Eligible engineering bench for suggestions: the four skill-carrying engineering members.
|
||||||
|
- Build Triage Draft keyword-matches ticketSummary+llmSummary against BENCH regexes (one member per specialty class: sophos/firewall/vpn/network; veeam/backup/vmware/exchange server; desktop/printer/wireless/phones/m365; workstation hw/peripherals) and appends `Suggested assignee (skills heuristic, NOT an assignment): <ID> (matched: <kw>)` or falls back to team default. suggestedOwners array rides the item for future routing.
|
||||||
|
- When CW skills change, edit the BENCH array in Build Triage Draft jsCode - static on purpose (no per-ticket CW skills API calls).
|
||||||
|
|
||||||
|
## Observe-dedupe re-escalation gap (2026-10-27, exec pinned)
|
||||||
|
|
||||||
|
- Dedupe keys on `ticketId + modelVersion` and suppresses EVERYTHING already triaged under the live version — including tickets whose content escalated since their note was posted. A ticket re-scored as security-risk/urgency-5 on a later run posts no new note, so the on-ticket record can be stale and materially wrong while the run looks clean.
|
||||||
|
- Run classification still processes suppressed tickets fully (fan-out through T2/T3); suppression happens only at the 'Not Yet Triaged?' node. A materially-changed re-score (tier up, new security verdict, urgency ≥4) is an escalation signal to surface to Scott with the T3 narrative — not noise.
|
||||||
|
- Dedupe-suppressed ≠ silent failure. Confirm via the audit read-back filtered on fetched ticketIds + live modelVersion before claiming either: row exists → by-design suppression (report suppressed IDs with their prior triage from the old row); row missing → bug.
|
||||||
|
- Force a fresh note on ONE ticket: deleteRows its audit row via a throwaway workflow, then re-execute — dedupe then passes only that ticket. Do NOT bump modelVersion to re-note a single ticket: the next run re-notes every already-triaged in-scope ticket.
|
||||||
|
|
||||||
|
## Lessons
|
||||||
|
|
||||||
|
- Memored ticket IDs (119222, 121765) were stale (both `>Closed` by 2026-10-04) fetched as `id = X OR id = Y` successfully; for live tests use `closedFlag = false` + orderBy — never recall 'active tickets' from memory.
|
||||||
|
- `create_workflow_from_code` takes `name` / `projectId` as TOOL args at top level (not `workflowName`); workflowName comes from first arg of workflow() in code.
|
||||||
|
- One-off synthetic-tripwire tests belong to scratch workflows; delete them after verification so only the observe-test skeleton remains.
|
||||||
Reference in new issue
Block a user